Add a pivot widget to selected layer(s) to control the origin(s) (#772)

* Add pivot

* Add dragging pivot

* Cleanup

* Remove tabs

* Fix multiplication order

* Restyle pivot

* Add move cursor icon

* Update pivot size

* Code review tweaks

* Fix alt with non-centred pivot

* Comment for add one

* Pivot sets layer panel origin

* Tweek alt centre thing

* Fix division by zero case

* Add pivot dots to properties

* FIx some typos

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
0HyperCube 2022-08-30 22:36:33 +01:00 committed by Keavon Chambers
parent 0f6f3be6e7
commit 1e109dc552
13 changed files with 360 additions and 37 deletions

View File

@ -43,6 +43,9 @@ pub const BIG_NUDGE_AMOUNT: f64 = 10.;
// Select tool // Select tool
pub const SELECTION_TOLERANCE: f64 = 5.; pub const SELECTION_TOLERANCE: f64 = 5.;
pub const SELECTION_DRAG_ANGLE: f64 = 90.; pub const SELECTION_DRAG_ANGLE: f64 = 90.;
pub const PIVOT_OUTER_OUTLINE_THICKNESS: f64 = 1.;
pub const PIVOT_OUTER: f64 = 9.;
pub const PIVOT_INNER: f64 = 3.;
// Transformation cage // Transformation cage
pub const BOUNDS_SELECT_THRESHOLD: f64 = 10.; pub const BOUNDS_SELECT_THRESHOLD: f64 = 10.;

View File

@ -25,6 +25,7 @@ pub enum MouseCursorIcon {
Grabbing, Grabbing,
Crosshair, Crosshair,
Text, Text,
Move,
NSResize, NSResize,
EWResize, EWResize,
NESWResize, NESWResize,

View File

@ -1,4 +1,5 @@
use derivative::*; use derivative::*;
use glam::DVec2;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::messages::layout::utility_types::layout_widget::WidgetCallback; use crate::messages::layout::utility_types::layout_widget::WidgetCallback;
@ -14,7 +15,7 @@ pub struct PivotAssist {
pub on_update: WidgetCallback<PivotAssist>, pub on_update: WidgetCallback<PivotAssist>,
} }
#[derive(Clone, Serialize, Deserialize, Debug, Default, PartialEq, Eq)] #[derive(Clone, Copy, Serialize, Deserialize, Debug, Default, PartialEq, Eq)]
pub enum PivotPosition { pub enum PivotPosition {
#[default] #[default]
None, None,
@ -46,3 +47,52 @@ impl From<&str> for PivotPosition {
} }
} }
} }
impl From<PivotPosition> for Option<DVec2> {
fn from(input: PivotPosition) -> Self {
match input {
PivotPosition::None => None,
PivotPosition::TopLeft => Some(DVec2::new(0., 0.)),
PivotPosition::TopCenter => Some(DVec2::new(0.5, 0.)),
PivotPosition::TopRight => Some(DVec2::new(1., 0.)),
PivotPosition::CenterLeft => Some(DVec2::new(0., 0.5)),
PivotPosition::Center => Some(DVec2::new(0.5, 0.5)),
PivotPosition::CenterRight => Some(DVec2::new(1., 0.5)),
PivotPosition::BottomLeft => Some(DVec2::new(0., 1.)),
PivotPosition::BottomCenter => Some(DVec2::new(0.5, 1.)),
PivotPosition::BottomRight => Some(DVec2::new(1., 1.)),
}
}
}
impl From<DVec2> for PivotPosition {
fn from(input: DVec2) -> Self {
const TOLERANCE: f64 = 1e-5_f64;
if input.y.abs() < TOLERANCE {
if input.x.abs() < TOLERANCE {
return PivotPosition::TopLeft;
} else if (input.x - 0.5).abs() < TOLERANCE {
return PivotPosition::TopCenter;
} else if (input.x - 1.).abs() < TOLERANCE {
return PivotPosition::TopRight;
}
} else if (input.y - 0.5).abs() < TOLERANCE {
if input.x.abs() < TOLERANCE {
return PivotPosition::CenterLeft;
} else if (input.x - 0.5).abs() < TOLERANCE {
return PivotPosition::Center;
} else if (input.x - 1.).abs() < TOLERANCE {
return PivotPosition::CenterRight;
}
} else if (input.y - 1.).abs() < TOLERANCE {
if input.x.abs() < TOLERANCE {
return PivotPosition::BottomLeft;
} else if (input.x - 0.5).abs() < TOLERANCE {
return PivotPosition::BottomCenter;
} else if (input.x - 1.).abs() < TOLERANCE {
return PivotPosition::BottomRight;
}
}
PivotPosition::None
}
}

View File

@ -1,4 +1,5 @@
use super::utility_types::TransformOp; use super::utility_types::TransformOp;
use crate::messages::layout::utility_types::widgets::assist_widgets::PivotPosition;
use crate::messages::portfolio::document::utility_types::misc::TargetDocument; use crate::messages::portfolio::document::utility_types::misc::TargetDocument;
use crate::messages::prelude::*; use crate::messages::prelude::*;
@ -25,5 +26,6 @@ pub enum PropertiesPanelMessage {
ModifyTransform { value: f64, transform_op: TransformOp }, ModifyTransform { value: f64, transform_op: TransformOp },
ResendActiveProperties, ResendActiveProperties,
SetActiveLayers { paths: Vec<Vec<LayerId>>, document: TargetDocument }, SetActiveLayers { paths: Vec<Vec<LayerId>>, document: TargetDocument },
SetPivot { new_position: PivotPosition },
UpdateSelectedDocumentProperties, UpdateSelectedDocumentProperties,
} }

View File

@ -101,6 +101,13 @@ impl<'a> MessageHandler<PropertiesPanelMessage, PropertiesPanelMessageHandlerDat
let (path, _) = self.active_selection.clone().expect("Received update for properties panel with no active layer"); let (path, _) = self.active_selection.clone().expect("Received update for properties panel with no active layer");
responses.push_back(Operation::SetTextContent { path, new_text }.into()) responses.push_back(Operation::SetTextContent { path, new_text }.into())
} }
SetPivot { new_position } => {
let (layer_path, _) = self.active_selection.clone().expect("Received update for properties panel with no active layer");
let position: Option<glam::DVec2> = new_position.into();
let pivot = position.unwrap().into();
responses.push_back(Operation::SetPivot { layer_path, pivot }.into());
}
CheckSelectedWasUpdated { path } => { CheckSelectedWasUpdated { path } => {
if self.matches_selected(&path) { if self.matches_selected(&path) {
responses.push_back(PropertiesPanelMessage::ResendActiveProperties.into()) responses.push_back(PropertiesPanelMessage::ResendActiveProperties.into())

View File

@ -1,6 +1,7 @@
use super::utility_types::TransformOp; use super::utility_types::TransformOp;
use crate::messages::layout::utility_types::layout_widget::{Layout, LayoutGroup, Widget, WidgetCallback, WidgetHolder, WidgetLayout}; use crate::messages::layout::utility_types::layout_widget::{Layout, LayoutGroup, Widget, WidgetCallback, WidgetHolder, WidgetLayout};
use crate::messages::layout::utility_types::misc::LayoutTarget; use crate::messages::layout::utility_types::misc::LayoutTarget;
use crate::messages::layout::utility_types::widgets::assist_widgets::PivotAssist;
use crate::messages::layout::utility_types::widgets::button_widgets::PopoverButton; use crate::messages::layout::utility_types::widgets::button_widgets::PopoverButton;
use crate::messages::layout::utility_types::widgets::input_widgets::{ColorInput, FontInput, NumberInput, RadioEntryData, RadioInput, TextAreaInput, TextInput}; use crate::messages::layout::utility_types::widgets::input_widgets::{ColorInput, FontInput, NumberInput, RadioEntryData, RadioInput, TextAreaInput, TextInput};
use crate::messages::layout::utility_types::widgets::label_widgets::{IconLabel, IconStyle, Separator, SeparatorDirection, SeparatorType, TextLabel}; use crate::messages::layout::utility_types::widgets::label_widgets::{IconLabel, IconStyle, Separator, SeparatorDirection, SeparatorType, TextLabel};
@ -80,6 +81,7 @@ pub fn register_artboard_layer_properties(layer: &Layer, responses: &mut VecDequ
} else { } else {
panic!("Artboard must have a solid fill") panic!("Artboard must have a solid fill")
}; };
let pivot = layer.transform.transform_vector2(layer.layerspace_pivot(font_cache));
vec![LayoutGroup::Section { vec![LayoutGroup::Section {
name: "Artboard".into(), name: "Artboard".into(),
@ -95,12 +97,12 @@ pub fn register_artboard_layer_properties(layer: &Layer, responses: &mut VecDequ
direction: SeparatorDirection::Horizontal, direction: SeparatorDirection::Horizontal,
})), })),
WidgetHolder::new(Widget::NumberInput(NumberInput { WidgetHolder::new(Widget::NumberInput(NumberInput {
value: Some(layer.transform.x()), value: Some(layer.transform.x() + pivot.x),
label: "X".into(), label: "X".into(),
unit: " px".into(), unit: " px".into(),
on_update: WidgetCallback::new(|number_input: &NumberInput| { on_update: WidgetCallback::new(move |number_input: &NumberInput| {
PropertiesPanelMessage::ModifyTransform { PropertiesPanelMessage::ModifyTransform {
value: number_input.value.unwrap(), value: number_input.value.unwrap() - pivot.x,
transform_op: TransformOp::X, transform_op: TransformOp::X,
} }
.into() .into()
@ -112,12 +114,12 @@ pub fn register_artboard_layer_properties(layer: &Layer, responses: &mut VecDequ
direction: SeparatorDirection::Horizontal, direction: SeparatorDirection::Horizontal,
})), })),
WidgetHolder::new(Widget::NumberInput(NumberInput { WidgetHolder::new(Widget::NumberInput(NumberInput {
value: Some(layer.transform.y()), value: Some(layer.transform.y() + pivot.y),
label: "Y".into(), label: "Y".into(),
unit: " px".into(), unit: " px".into(),
on_update: WidgetCallback::new(|number_input: &NumberInput| { on_update: WidgetCallback::new(move |number_input: &NumberInput| {
PropertiesPanelMessage::ModifyTransform { PropertiesPanelMessage::ModifyTransform {
value: number_input.value.unwrap(), value: number_input.value.unwrap() - pivot.y,
transform_op: TransformOp::Y, transform_op: TransformOp::Y,
} }
.into() .into()
@ -308,6 +310,7 @@ pub fn register_artwork_layer_properties(layer: &Layer, responses: &mut VecDeque
} }
fn node_section_transform(layer: &Layer, font_cache: &FontCache) -> LayoutGroup { fn node_section_transform(layer: &Layer, font_cache: &FontCache) -> LayoutGroup {
let pivot = layer.transform.transform_vector2(layer.layerspace_pivot(font_cache));
LayoutGroup::Section { LayoutGroup::Section {
name: "Transform".into(), name: "Transform".into(),
layout: vec![ layout: vec![
@ -317,17 +320,25 @@ fn node_section_transform(layer: &Layer, font_cache: &FontCache) -> LayoutGroup
value: "Location".into(), value: "Location".into(),
..TextLabel::default() ..TextLabel::default()
})), })),
WidgetHolder::new(Widget::Separator(Separator {
separator_type: SeparatorType::Related,
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::PivotAssist(PivotAssist {
position: layer.pivot.into(),
on_update: WidgetCallback::new(|pivot_assist: &PivotAssist| PropertiesPanelMessage::SetPivot { new_position: pivot_assist.position }.into()),
})),
WidgetHolder::new(Widget::Separator(Separator { WidgetHolder::new(Widget::Separator(Separator {
separator_type: SeparatorType::Unrelated, separator_type: SeparatorType::Unrelated,
direction: SeparatorDirection::Horizontal, direction: SeparatorDirection::Horizontal,
})), })),
WidgetHolder::new(Widget::NumberInput(NumberInput { WidgetHolder::new(Widget::NumberInput(NumberInput {
value: Some(layer.transform.x()), value: Some(layer.transform.x() + pivot.x),
label: "X".into(), label: "X".into(),
unit: " px".into(), unit: " px".into(),
on_update: WidgetCallback::new(|number_input: &NumberInput| { on_update: WidgetCallback::new(move |number_input: &NumberInput| {
PropertiesPanelMessage::ModifyTransform { PropertiesPanelMessage::ModifyTransform {
value: number_input.value.unwrap(), value: number_input.value.unwrap() - pivot.x,
transform_op: TransformOp::X, transform_op: TransformOp::X,
} }
.into() .into()
@ -339,12 +350,12 @@ fn node_section_transform(layer: &Layer, font_cache: &FontCache) -> LayoutGroup
direction: SeparatorDirection::Horizontal, direction: SeparatorDirection::Horizontal,
})), })),
WidgetHolder::new(Widget::NumberInput(NumberInput { WidgetHolder::new(Widget::NumberInput(NumberInput {
value: Some(layer.transform.y()), value: Some(layer.transform.y() + pivot.y),
label: "Y".into(), label: "Y".into(),
unit: " px".into(), unit: " px".into(),
on_update: WidgetCallback::new(|number_input: &NumberInput| { on_update: WidgetCallback::new(move |number_input: &NumberInput| {
PropertiesPanelMessage::ModifyTransform { PropertiesPanelMessage::ModifyTransform {
value: number_input.value.unwrap(), value: number_input.value.unwrap() - pivot.y,
transform_op: TransformOp::Y, transform_op: TransformOp::Y,
} }
.into() .into()

View File

@ -1,5 +1,6 @@
pub mod overlay_renderer; pub mod overlay_renderer;
pub mod path_outline; pub mod path_outline;
pub mod pivot;
pub mod resize; pub mod resize;
pub mod shape_editor; pub mod shape_editor;
pub mod snapping; pub mod snapping;

View File

@ -0,0 +1,177 @@
//! Handler for the pivot overlay visible on the selected layer(s) whilst using the Select tool which controls the center of rotation/scale and origin of the layer.
use crate::application::generate_uuid;
use crate::consts::{COLOR_ACCENT, PIVOT_INNER, PIVOT_OUTER, PIVOT_OUTER_OUTLINE_THICKNESS};
use crate::messages::layout::utility_types::widgets::assist_widgets::PivotPosition;
use crate::messages::prelude::*;
use graphene::layers::style;
use graphene::layers::text_layer::FontCache;
use graphene::{LayerId, Operation};
use glam::{DAffine2, DVec2};
use std::collections::VecDeque;
#[derive(Clone, Debug)]
pub struct Pivot {
/// Pivot between (0,0) and (1,1)
normalized_pivot: DVec2,
/// Transform to get from normalized pivot to viewspace
transform_from_normalized: DAffine2,
/// The viewspace pivot position (if applicable)
pivot: Option<DVec2>,
/// A reference to the previous overlays so we can destroy them
pivot_overlay_circles: Option<[Vec<LayerId>; 2]>,
/// The old pivot position in the GUI, used to reduce refreshes of the document bar
old_pivot_position: PivotPosition,
}
impl Default for Pivot {
fn default() -> Self {
Self {
normalized_pivot: DVec2::splat(0.5),
transform_from_normalized: Default::default(),
pivot: Default::default(),
pivot_overlay_circles: Default::default(),
old_pivot_position: PivotPosition::Center,
}
}
}
impl Pivot {
/// Calculates the transform that gets from normalized pivot to viewspace.
fn get_layer_pivot_transform(layer_path: &[LayerId], layer: &graphene::layers::layer_info::Layer, document: &DocumentMessageHandler, font_cache: &FontCache) -> DAffine2 {
let [min, max] = layer.aabb_for_transform(DAffine2::IDENTITY, font_cache).unwrap_or([DVec2::ZERO, DVec2::ONE]);
let bounds_transform = DAffine2::from_translation(min) * DAffine2::from_scale(max - min);
let layer_transform = document.graphene_document.multiply_transforms(layer_path).unwrap_or(DAffine2::IDENTITY);
layer_transform * bounds_transform
}
/// Recomputes the pivot position and transform.
fn recalculate_pivot(&mut self, document: &DocumentMessageHandler, font_cache: &FontCache) {
let mut layers = document.selected_visible_layers();
if let Some(first) = layers.next() {
// Add one because the first item is consumed above.
let selected_layers_count = layers.count() + 1;
// If just one layer is selected we can use its inner transform
if selected_layers_count == 1 {
if let Ok(layer) = document.graphene_document.layer(first) {
self.normalized_pivot = layer.pivot;
self.transform_from_normalized = Self::get_layer_pivot_transform(first, layer, document, font_cache);
self.pivot = Some(self.transform_from_normalized.transform_point2(layer.pivot));
}
} else {
// If more than one layer is selected we use the AABB with the mean of the pivots
let xy_summation = document
.selected_visible_layers()
.filter_map(|path| document.graphene_document.pivot(path, font_cache))
.reduce(|a, b| a + b)
.unwrap_or_default();
let pivot = xy_summation / selected_layers_count as f64;
self.pivot = Some(pivot);
let [min, max] = document.selected_visible_layers_bounding_box(font_cache).unwrap_or([DVec2::ZERO, DVec2::ONE]);
self.normalized_pivot = (pivot - min) / (max - min);
self.transform_from_normalized = DAffine2::from_translation(min) * DAffine2::from_scale(max - min);
}
} else {
// If no layers are selected then we revert things back to default
self.normalized_pivot = DVec2::splat(0.5);
self.pivot = None;
}
}
pub fn clear_overlays(&mut self, responses: &mut VecDeque<Message>) {
if let Some(overlays) = self.pivot_overlay_circles.take() {
for path in overlays {
responses.push_back(DocumentMessage::Overlays(Operation::DeleteLayer { path }.into()).into());
}
}
}
fn redraw_pivot(&mut self, responses: &mut VecDeque<Message>) {
self.clear_overlays(responses);
let pivot = match self.pivot {
Some(pivot) => pivot,
None => return,
};
let layer_paths = [vec![generate_uuid()], vec![generate_uuid()]];
responses.push_back(
DocumentMessage::Overlays(
Operation::AddEllipse {
path: layer_paths[0].clone(),
transform: DAffine2::IDENTITY.to_cols_array(),
style: style::PathStyle::new(Some(style::Stroke::new(COLOR_ACCENT, PIVOT_OUTER_OUTLINE_THICKNESS)), style::Fill::Solid(graphene::color::Color::WHITE)),
insert_index: -1,
}
.into(),
)
.into(),
);
responses.push_back(
DocumentMessage::Overlays(
Operation::AddEllipse {
path: layer_paths[1].clone(),
transform: DAffine2::IDENTITY.to_cols_array(),
style: style::PathStyle::new(None, style::Fill::Solid(COLOR_ACCENT)),
insert_index: -1,
}
.into(),
)
.into(),
);
self.pivot_overlay_circles = Some(layer_paths.clone());
let [outer, inner] = layer_paths;
let pivot_diameter_without_outline = PIVOT_OUTER - PIVOT_OUTER_OUTLINE_THICKNESS;
let transform = DAffine2::from_scale_angle_translation(DVec2::splat(pivot_diameter_without_outline), 0., pivot - DVec2::splat(pivot_diameter_without_outline / 2.)).to_cols_array();
responses.push_back(DocumentMessage::Overlays(Operation::TransformLayerInViewport { path: outer, transform }.into()).into());
let transform = DAffine2::from_scale_angle_translation(DVec2::splat(PIVOT_INNER), 0., pivot - DVec2::splat(PIVOT_INNER / 2.)).to_cols_array();
responses.push_back(DocumentMessage::Overlays(Operation::TransformLayerInViewport { path: inner, transform }.into()).into());
}
pub fn update_pivot(&mut self, document: &DocumentMessageHandler, font_cache: &FontCache, responses: &mut VecDeque<Message>) {
self.recalculate_pivot(document, font_cache);
self.redraw_pivot(responses);
}
/// Answers if the pivot widget has changed (so we should refresh the tool bar at the top of the canvas).
pub fn should_refresh_pivot_position(&mut self) -> bool {
let new = self.to_pivot_position();
let should_refresh = new != self.old_pivot_position;
self.old_pivot_position = new;
should_refresh
}
pub fn to_pivot_position(&self) -> PivotPosition {
self.normalized_pivot.into()
}
/// Sets the viewport position of the pivot for all selected layers.
pub fn set_viewport_position(&self, position: DVec2, document: &DocumentMessageHandler, font_cache: &FontCache, responses: &mut VecDeque<Message>) {
for layer_path in document.selected_visible_layers() {
if let Ok(layer) = document.graphene_document.layer(layer_path) {
let transform = Self::get_layer_pivot_transform(layer_path, layer, document, font_cache);
let pivot = transform.inverse().transform_point2(position).into();
let layer_path = layer_path.to_owned();
responses.push_back(Operation::SetPivot { layer_path, pivot }.into());
}
}
}
/// Set the pivot using the normalized transform that is set above.
pub fn set_normalized_position(&self, position: DVec2, document: &DocumentMessageHandler, font_cache: &FontCache, responses: &mut VecDeque<Message>) {
self.set_viewport_position(self.transform_from_normalized.transform_point2(position), document, font_cache, responses);
}
/// Answers if the pointer is currently positioned over the pivot.
pub fn is_over(&self, mouse: DVec2) -> bool {
self.pivot.filter(|&pivot| mouse.distance_squared(pivot) < (PIVOT_OUTER / 2.).powi(2)).is_some()
}
}

View File

@ -81,21 +81,35 @@ impl SelectedEdges {
let mut pivot = self.pivot_from_bounds(min, max); let mut pivot = self.pivot_from_bounds(min, max);
if center { if center {
// The below ratio is: `dragging edge / being centred`.
// The `is_finite()` checks are in case the user is dragging the edge where the pivot is located (in which case the centering mode is ignored).
if self.top { if self.top {
max.y = center_around.y * 2. - min.y; let ratio = (center_around.y - min.y) / (center_around.y - self.bounds[0].y);
pivot.y = center_around.y; if ratio.is_finite() {
} else if self.bottom { max.y = center_around.y + ratio * (self.bounds[1].y - center_around.y);
min.y = center_around.y * 2. - max.y;
pivot.y = center_around.y; pivot.y = center_around.y;
} }
} else if self.bottom {
let ratio = (max.y - center_around.y) / (self.bounds[1].y - center_around.y);
if ratio.is_finite() {
min.y = center_around.y - ratio * (center_around.y - self.bounds[0].y);
pivot.y = center_around.y;
}
}
if self.left { if self.left {
max.x = center_around.x * 2. - min.x; let ratio = (center_around.x - min.x) / (center_around.x - self.bounds[0].x);
if ratio.is_finite() {
max.x = center_around.x + ratio * (self.bounds[1].x - center_around.x);
pivot.x = center_around.x; pivot.x = center_around.x;
}
} else if self.right { } else if self.right {
min.x = center_around.x * 2. - max.x; let ratio = (max.x - center_around.x) / (self.bounds[1].x - center_around.x);
if ratio.is_finite() {
min.x = center_around.x - ratio * (center_around.x - self.bounds[0].x);
pivot.x = center_around.x; pivot.x = center_around.x;
} }
} }
}
if constrain { if constrain {
let size = max - min; let size = max - min;
@ -115,8 +129,16 @@ impl SelectedEdges {
} }
/// Calculates the required scaling to resize the bounding box /// Calculates the required scaling to resize the bounding box
pub fn bounds_to_scale_transform(&self, size: DVec2) -> DAffine2 { pub fn bounds_to_scale_transform(&self, position: DVec2, size: DVec2) -> (DAffine2, DVec2) {
DAffine2::from_scale(size / (self.bounds[1] - self.bounds[0])) let enlargement_factor = size / (self.bounds[1] - self.bounds[0]);
let mut pivot = (self.bounds[0] * enlargement_factor - position) / (enlargement_factor - DVec2::splat(1.));
if pivot.x.is_nan() {
pivot.x = 0.;
}
if pivot.y.is_nan() {
pivot.y = 0.;
}
(DAffine2::from_scale(enlargement_factor), pivot)
} }
} }

View File

@ -4,6 +4,7 @@ use crate::messages::frontend::utility_types::MouseCursorIcon;
use crate::messages::input_mapper::utility_types::input_keyboard::{Key, KeysGroup, MouseMotion}; use crate::messages::input_mapper::utility_types::input_keyboard::{Key, KeysGroup, MouseMotion};
use crate::messages::input_mapper::utility_types::input_mouse::ViewportPosition; use crate::messages::input_mapper::utility_types::input_mouse::ViewportPosition;
use crate::messages::layout::utility_types::layout_widget::{Layout, LayoutGroup, PropertyHolder, Widget, WidgetCallback, WidgetHolder, WidgetLayout}; use crate::messages::layout::utility_types::layout_widget::{Layout, LayoutGroup, PropertyHolder, Widget, WidgetCallback, WidgetHolder, WidgetLayout};
use crate::messages::layout::utility_types::misc::LayoutTarget;
use crate::messages::layout::utility_types::widgets::assist_widgets::{PivotAssist, PivotPosition}; use crate::messages::layout::utility_types::widgets::assist_widgets::{PivotAssist, PivotPosition};
use crate::messages::layout::utility_types::widgets::button_widgets::{IconButton, PopoverButton}; use crate::messages::layout::utility_types::widgets::button_widgets::{IconButton, PopoverButton};
use crate::messages::layout::utility_types::widgets::label_widgets::{Separator, SeparatorDirection, SeparatorType}; use crate::messages::layout::utility_types::widgets::label_widgets::{Separator, SeparatorDirection, SeparatorType};
@ -11,6 +12,7 @@ use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate,
use crate::messages::portfolio::document::utility_types::transformation::Selected; use crate::messages::portfolio::document::utility_types::transformation::Selected;
use crate::messages::prelude::*; use crate::messages::prelude::*;
use crate::messages::tool::common_functionality::path_outline::*; use crate::messages::tool::common_functionality::path_outline::*;
use crate::messages::tool::common_functionality::pivot::Pivot;
use crate::messages::tool::common_functionality::snapping::{self, SnapManager}; use crate::messages::tool::common_functionality::snapping::{self, SnapManager};
use crate::messages::tool::common_functionality::transformation_cage::*; use crate::messages::tool::common_functionality::transformation_cage::*;
use crate::messages::tool::utility_types::{EventToMessageMap, Fsm, ToolActionHandlerData, ToolMetadata, ToolTransition, ToolType}; use crate::messages::tool::utility_types::{EventToMessageMap, Fsm, ToolActionHandlerData, ToolMetadata, ToolTransition, ToolType};
@ -34,7 +36,7 @@ pub struct SelectTool {
#[remain::sorted] #[remain::sorted]
#[impl_message(Message, ToolMessage, Select)] #[impl_message(Message, ToolMessage, Select)]
#[derive(PartialEq, Eq, Clone, Debug, Hash, Serialize, Deserialize)] #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)]
pub enum SelectToolMessage { pub enum SelectToolMessage {
// Standard messages // Standard messages
#[remain::unsorted] #[remain::unsorted]
@ -62,6 +64,9 @@ pub enum SelectToolMessage {
center: Key, center: Key,
duplicate: Key, duplicate: Key,
}, },
SetPivot {
position: PivotPosition,
},
} }
impl ToolMetadata for SelectTool { impl ToolMetadata for SelectTool {
@ -252,12 +257,8 @@ impl PropertyHolder for SelectTool {
})), })),
// We'd like this widget to hide and show itself whenever the transformation cage is active or inactive (i.e. when no layers are selected) // We'd like this widget to hide and show itself whenever the transformation cage is active or inactive (i.e. when no layers are selected)
WidgetHolder::new(Widget::PivotAssist(PivotAssist { WidgetHolder::new(Widget::PivotAssist(PivotAssist {
position: PivotPosition::Center, position: self.tool_data.pivot.to_pivot_position(),
on_update: WidgetCallback::new(|pivot_assist: &PivotAssist| { on_update: WidgetCallback::new(|pivot_assist: &PivotAssist| SelectToolMessage::SetPivot { position: pivot_assist.position }.into()),
// TODO: Make this actually do something
log::debug!("Changed pivot to {:?}", pivot_assist.position);
Message::NoOp
}),
})), })),
], ],
}])) }]))
@ -278,6 +279,11 @@ impl<'a> MessageHandler<ToolMessage, ToolActionHandlerData<'a>> for SelectTool {
let new_state = self.fsm_state.transition(message, &mut self.tool_data, tool_data, &(), responses); let new_state = self.fsm_state.transition(message, &mut self.tool_data, tool_data, &(), responses);
if self.tool_data.pivot.should_refresh_pivot_position() {
// Notify the frontend about the updated pivot position (a bit ugly to do it here not in the fsm but that doesn't have SelectTool)
self.register_properties(responses, LayoutTarget::ToolOptions);
}
if self.fsm_state != new_state { if self.fsm_state != new_state {
self.fsm_state = new_state; self.fsm_state = new_state;
self.fsm_state.update_hints(responses); self.fsm_state.update_hints(responses);
@ -321,6 +327,7 @@ enum SelectToolFsmState {
DrawingBox, DrawingBox,
ResizingBounds, ResizingBounds,
RotatingBounds, RotatingBounds,
DraggingPivot,
} }
impl Default for SelectToolFsmState { impl Default for SelectToolFsmState {
@ -340,6 +347,7 @@ struct SelectToolData {
bounding_box_overlays: Option<BoundingBoxOverlays>, bounding_box_overlays: Option<BoundingBoxOverlays>,
snap_manager: SnapManager, snap_manager: SnapManager,
cursor: MouseCursorIcon, cursor: MouseCursorIcon,
pivot: Pivot,
} }
impl SelectToolData { impl SelectToolData {
@ -393,6 +401,7 @@ impl Fsm for SelectToolFsmState {
tool_data.path_outlines.update_selected(document.selected_visible_layers(), document, responses, font_cache); tool_data.path_outlines.update_selected(document.selected_visible_layers(), document, responses, font_cache);
tool_data.path_outlines.intersect_test_hovered(input, document, responses, font_cache); tool_data.path_outlines.intersect_test_hovered(input, document, responses, font_cache);
tool_data.pivot.update_pivot(document, font_cache, responses);
self self
} }
@ -455,7 +464,12 @@ impl Fsm for SelectToolFsmState {
// If the user clicks on a layer that is in their current selection, go into the dragging mode. // If the user clicks on a layer that is in their current selection, go into the dragging mode.
// If the user clicks on new shape, make that layer their new selection. // If the user clicks on new shape, make that layer their new selection.
// Otherwise enter the box select mode // Otherwise enter the box select mode
let state = if let Some(selected_edges) = dragging_bounds { let state = if tool_data.pivot.is_over(input.mouse.position) {
tool_data.snap_manager.start_snap(document, document.bounding_boxes(None, None, font_cache), true, true);
tool_data.snap_manager.add_all_document_handles(document, &[], &[], &[]);
DraggingPivot
} else if let Some(selected_edges) = dragging_bounds {
let snap_x = selected_edges.2 || selected_edges.3; let snap_x = selected_edges.2 || selected_edges.3;
let snap_y = selected_edges.0 || selected_edges.1; let snap_y = selected_edges.0 || selected_edges.1;
@ -565,12 +579,11 @@ impl Fsm for SelectToolFsmState {
let snapped_mouse_position = tool_data.snap_manager.snap_position(responses, document, mouse_position); let snapped_mouse_position = tool_data.snap_manager.snap_position(responses, document, mouse_position);
let (_, size) = movement.new_size(snapped_mouse_position, bounds.transform, center, bounds.center_of_transformation, axis_align); let (position, size) = movement.new_size(snapped_mouse_position, bounds.transform, center, bounds.center_of_transformation, axis_align);
let delta = movement.bounds_to_scale_transform(size); let (delta, mut pivot) = movement.bounds_to_scale_transform(position, size);
let selected = &tool_data.layers_dragging.iter().collect::<Vec<_>>(); let selected = &tool_data.layers_dragging.iter().collect::<Vec<_>>();
let pivot = if center { &mut bounds.center_of_transformation } else { &mut bounds.opposite_pivot }; let mut selected = Selected::new(&mut bounds.original_transforms, &mut pivot, selected, responses, &document.graphene_document);
let mut selected = Selected::new(&mut bounds.original_transforms, pivot, selected, responses, &document.graphene_document);
selected.update_transforms(delta); selected.update_transforms(delta);
} }
@ -603,6 +616,13 @@ impl Fsm for SelectToolFsmState {
RotatingBounds RotatingBounds
} }
(DraggingPivot, PointerMove { .. }) => {
let mouse_position = input.mouse.position;
let snapped_mouse_position = tool_data.snap_manager.snap_position(responses, document, mouse_position);
tool_data.pivot.set_viewport_position(snapped_mouse_position, document, font_cache, responses);
DraggingPivot
}
(DrawingBox, PointerMove { .. }) => { (DrawingBox, PointerMove { .. }) => {
tool_data.drag_current = input.mouse.position; tool_data.drag_current = input.mouse.position;
@ -619,7 +639,12 @@ impl Fsm for SelectToolFsmState {
DrawingBox DrawingBox
} }
(Ready, PointerMove { .. }) => { (Ready, PointerMove { .. }) => {
let cursor = tool_data.bounding_box_overlays.as_ref().map_or(MouseCursorIcon::Default, |bounds| bounds.get_cursor(input, true)); let mut cursor = tool_data.bounding_box_overlays.as_ref().map_or(MouseCursorIcon::Default, |bounds| bounds.get_cursor(input, true));
// Dragging the pivot overrules the other operations
if tool_data.pivot.is_over(input.mouse.position) {
cursor = MouseCursorIcon::Move;
}
// Generate the select outline (but not if the user is going to use the bound overlays) // Generate the select outline (but not if the user is going to use the bound overlays)
if cursor == MouseCursorIcon::Default { if cursor == MouseCursorIcon::Default {
@ -660,6 +685,11 @@ impl Fsm for SelectToolFsmState {
Ready Ready
} }
(DraggingPivot, DragStop) => {
tool_data.snap_manager.cleanup(responses);
Ready
}
(DrawingBox, DragStop) => { (DrawingBox, DragStop) => {
let quad = tool_data.selection_quad(); let quad = tool_data.selection_quad();
responses.push_front( responses.push_front(
@ -684,6 +714,7 @@ impl Fsm for SelectToolFsmState {
responses.push_back(DocumentMessage::Undo.into()); responses.push_back(DocumentMessage::Undo.into());
tool_data.path_outlines.clear_selected(responses); tool_data.path_outlines.clear_selected(responses);
tool_data.pivot.clear_overlays(responses);
Ready Ready
} }
@ -708,6 +739,7 @@ impl Fsm for SelectToolFsmState {
tool_data.path_outlines.clear_hovered(responses); tool_data.path_outlines.clear_hovered(responses);
tool_data.path_outlines.clear_selected(responses); tool_data.path_outlines.clear_selected(responses);
tool_data.pivot.clear_overlays(responses);
tool_data.snap_manager.cleanup(responses); tool_data.snap_manager.cleanup(responses);
Ready Ready
@ -727,6 +759,12 @@ impl Fsm for SelectToolFsmState {
self self
} }
(_, SetPivot { position }) => {
let pos: Option<DVec2> = position.into();
tool_data.pivot.set_normalized_position(pos.unwrap(), document, font_cache, responses);
self
}
_ => self, _ => self,
} }
} else { } else {
@ -869,6 +907,7 @@ impl Fsm for SelectToolFsmState {
label: String::from("Snap 15°"), label: String::from("Snap 15°"),
plus: false, plus: false,
}])]), }])]),
SelectToolFsmState::DraggingPivot => HintData(vec![]),
}; };
responses.push_back(FrontendMessage::UpdateInputHints { hint_data }.into()); responses.push_back(FrontendMessage::UpdateInputHints { hint_data }.into());

View File

@ -195,6 +195,7 @@ const mouseCursorIconCSSNames = {
Grabbing: "grabbing", Grabbing: "grabbing",
Crosshair: "crosshair", Crosshair: "crosshair",
Text: "text", Text: "text",
Move: "move",
NSResize: "ns-resize", NSResize: "ns-resize",
EWResize: "ew-resize", EWResize: "ew-resize",
NESWResize: "nesw-resize", NESWResize: "nesw-resize",

View File

@ -760,7 +760,12 @@ impl Document {
self.mark_as_dirty(&path)?; self.mark_as_dirty(&path)?;
Some([vec![DocumentChanged, LayerChanged { path: path.clone() }], update_thumbnails_upstream(&path)].concat()) Some([vec![DocumentChanged, LayerChanged { path: path.clone() }], update_thumbnails_upstream(&path)].concat())
} }
Operation::SetPivot { layer_path, pivot } => {
let layer = self.layer_mut(&layer_path).expect("Setting pivot for invalid layer");
layer.pivot = pivot.into();
self.mark_as_dirty(&layer_path)?;
Some([vec![DocumentChanged, LayerChanged { path: layer_path.clone() }], update_thumbnails_upstream(&layer_path)].concat())
}
Operation::SetLayerTransformInViewport { path, transform } => { Operation::SetLayerTransformInViewport { path, transform } => {
let transform = DAffine2::from_cols_array(&transform); let transform = DAffine2::from_cols_array(&transform);
self.set_transform_relative_to_viewport(&path, transform)?; self.set_transform_relative_to_viewport(&path, transform)?;

View File

@ -56,6 +56,10 @@ pub enum Operation {
blob_url: String, blob_url: String,
dimensions: (f64, f64), dimensions: (f64, f64),
}, },
SetPivot {
layer_path: Vec<LayerId>,
pivot: (f64, f64),
},
SetTextEditability { SetTextEditability {
path: Vec<LayerId>, path: Vec<LayerId>,
editable: bool, editable: bool,