Repository: GraphiteEditor/Graphite (language: rust)

Issue:
G/R/S visualization overlays
G/R/S, used with both the Select and Path tools, should display an X or Y line when grabbing or scaling in the X or Y axis. (The line should be aligned with the common center, i.e. the pivot used for scaling or rotating.) It should go from one end of the viewport to the other. It should also display a dot around the pivot while rotating or scaling. Reference how other overlays are drawn (like those visualizing snapping relationships) for how to draw overlay shapes like lines and dots.

Also, text should be drawn next to those lines visualizing the numerical offsets. Currently these values are shown in the hints bar, which is a temporary workaround until this is implemented.

Also: a line should be drawn when dragging with the Select tool while holding <kbd>Shift</kbd> to constrain it to the X or Y axis. (That behavior already exists, but the line should be added to visualize it.)

- [x] #2195
- [x] #2229
- [x] #2243
- [x] #2285


Discussion / hints:
Commenting so you can assign.

Fix patch:
diff --git a/editor/src/messages/input_mapper/input_mappings.rs b/editor/src/messages/input_mapper/input_mappings.rs
index 37e33b320c..6e0a3f56c1 100644
--- a/editor/src/messages/input_mapper/input_mappings.rs
+++ b/editor/src/messages/input_mapper/input_mappings.rs
@@ -93,7 +93,7 @@ pub fn input_mappings() -> Mapping {
 		entry!(KeyDown(Minus); action_dispatch=TransformLayerMessage::TypeNegate),
 		entry!(KeyDown(Comma); action_dispatch=TransformLayerMessage::TypeDecimalPoint),
 		entry!(KeyDown(Period); action_dispatch=TransformLayerMessage::TypeDecimalPoint),
-		entry!(PointerMove; refresh_keys=[Control, Shift], action_dispatch=TransformLayerMessage::PointerMove { slow_key: Shift, snap_key: Control }),
+		entry!(PointerMove; refresh_keys=[Control, Shift], action_dispatch=TransformLayerMessage::PointerMove { slow_key: Shift, increments_key: Control }),
 		//
 		// SelectToolMessage
 		entry!(PointerMove; refresh_keys=[Control, Alt, Shift], action_dispatch=SelectToolMessage::PointerMove(SelectToolPointerKeys { axis_align: Shift, snap_angle: Control, center: Alt, duplicate: Alt })),
diff --git a/editor/src/messages/input_mapper/utility_types/input_keyboard.rs b/editor/src/messages/input_mapper/utility_types/input_keyboard.rs
index 625c33d437..00242efd24 100644
--- a/editor/src/messages/input_mapper/utility_types/input_keyboard.rs
+++ b/editor/src/messages/input_mapper/utility_types/input_keyboard.rs
@@ -295,6 +295,7 @@ impl fmt::Display for Key {
 			Self::MouseMiddle => "MMB",
 			Self::MouseBack => "Mouse Back",
 			Self::MouseForward => "Mouse Fwd",
+			Self::NumKeys => "0–9",
 
 			_ => key_name.as_str(),
 		};
@@ -317,20 +318,6 @@ struct LayoutKey {
 	key: String,
 	label: String,
 }
-/*
-impl Serialize for Key {
-	fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
-		let key = format!("{:?}", self.0);
-		let label = self.0.to_string();
-
-		assert_eq!(serde_json::to_string(Key::KeyEscape), {"key": KeyEscape, "label": "Esc"});
-
-		let mut state = serializer.serialize_struct("KeyWithLabel", 2)?;
-		state.serialize_field("key", &key)?;
-		state.serialize_field("label", &label)?;
-		state.end()
-	}
-}*/
 
 pub const NUMBER_OF_KEYS: usize = Key::NumKeys as usize;
 
diff --git a/editor/src/messages/portfolio/document/utility_types/transformation.rs b/editor/src/messages/portfolio/document/utility_types/transformation.rs
index b540b92868..315a91a983 100644
--- a/editor/src/messages/portfolio/document/utility_types/transformation.rs
+++ b/editor/src/messages/portfolio/document/utility_types/transformation.rs
@@ -143,8 +143,8 @@ pub struct Translation {
 }
 
 impl Translation {
-	pub fn to_dvec(self, transform: DAffine2) -> DVec2 {
-		if let Some(value) = self.typed_distance {
+	pub fn to_dvec(self, transform: DAffine2, snap: bool) -> DVec2 {
+		let displacement = if let Some(value) = self.typed_distance {
 			let document_displacement = if self.constraint == Axis::Y { DVec2::new(0., value) } else { DVec2::new(value, 0.) };
 			transform.transform_vector2(document_displacement)
 		} else {
@@ -153,6 +153,12 @@ impl Translation {
 				Axis::X => DVec2::new(self.dragged_distance.x, 0.),
 				Axis::Y => DVec2::new(0., self.dragged_distance.y),
 			}
+		};
+		let displacement = transform.inverse().transform_vector2(displacement);
+		if snap {
+			displacement.round()
+		} else {
+			displacement
 		}
 	}
 
@@ -172,6 +178,11 @@ impl Translation {
 		}
 	}
 
+	pub fn negate(self) -> Self {
+		let dragged_distance = -self.dragged_distance;
+		Self { dragged_distance, ..self }
+	}
+
 	pub fn with_constraint(self, target: Axis, local: bool) -> (Self, bool) {
 		let (constraint, local) = self.constraint.contrainted_to_axis(target, local);
 		(Self { constraint, ..self }, local)
@@ -297,10 +308,11 @@ impl TransformOperation {
 		if self != &TransformOperation::None {
 			let transformation = match self {
 				TransformOperation::Grabbing(translation) => {
+					let translate = DAffine2::from_translation(transform.transform_vector2(translation.to_dvec(transform, snapping)));
 					if local {
-						DAffine2::from_angle(edge.to_angle()) * DAffine2::from_translation(translation.to_dvec(transform)) * DAffine2::from_angle(-edge.to_angle())
+						DAffine2::from_angle(edge.to_angle()) * translate * DAffine2::from_angle(-edge.to_angle())
 					} else {
-						DAffine2::from_translation(translation.to_dvec(transform))
+						translate
 					}
 				}
 				TransformOperation::Rotating(rotation) => DAffine2::from_angle(rotation.to_f64(snapping)),
@@ -315,7 +327,16 @@ impl TransformOperation {
 			};
 
 			selected.update_transforms(transformation);
-			self.hints(selected.responses);
+			self.hints(selected.responses, local);
+		}
+	}
+
+	pub fn is_typing(&self) -> bool {
+		match self {
+			TransformOperation::None => false,
+			TransformOperation::Grabbing(translation) => translation.typed_distance.is_some(),
+			TransformOperation::Rotating(rotation) => rotation.typed_angle.is_some(),
+			TransformOperation::Scaling(scale) => scale.typed_factor.is_some(),
 		}
 	}
 
@@ -346,21 +367,61 @@ impl TransformOperation {
 		self.apply_transform_operation(selected, snapping, local, quad, transform);
 	}
 
-	pub fn hints(&self, responses: &mut VecDeque<Message>) {
+	pub fn hints(&self, responses: &mut VecDeque<Message>, local: bool) {
 		use crate::messages::input_mapper::utility_types::input_keyboard::Key;
 		use crate::messages::tool::utility_types::{HintData, HintGroup, HintInfo};
 
 		let mut input_hints = Vec::new();
-		input_hints.push(HintInfo::keys([Key::Shift], "Slow Mode"));
-		if matches!(self, TransformOperation::Rotating(_) | TransformOperation::Scaling(_)) {
-			input_hints.push(HintInfo::keys([Key::Control], "Snap"));
+		if self.is_typing() {
+			input_hints.push(HintInfo::keys([Key::Minus], "Negate Direction"));
+			input_hints.push(HintInfo::keys([Key::Backspace], "Delete Digit"));
+			input_hints.push(HintInfo::keys([Key::NumKeys], "Enter Number"));
+		} else if matches!(self, TransformOperation::Grabbing(_) | TransformOperation::Scaling(_)) {
+			let axis_constraint = match self {
+				TransformOperation::Grabbing(grabbing) => grabbing.constraint,
+				TransformOperation::Scaling(scaling) => scaling.constraint,
+				_ => Axis::Both,
+			};
+			let clear_constraint = "Clear Constraint";
+			match axis_constraint {
+				Axis::Both => {
+					input_hints.push(HintInfo::keys([Key::KeyX], "Along X Axis"));
+					input_hints.push(HintInfo::keys([Key::KeyY], "Along Y Axis"));
+				}
+				Axis::X => {
+					let x_label = if local { clear_constraint } else { "Along Local X Axis" };
+					input_hints.push(HintInfo::keys([Key::KeyX], x_label));
+					input_hints.push(HintInfo::keys([Key::KeyY], "Along Y Axis"));
+					if !local {
+						input_hints.push(HintInfo::keys([Key::KeyX, Key::KeyX], clear_constraint));
+					}
+				}
+				Axis::Y => {
+					let y_label = if local { clear_constraint } else { "Along Local Y Axis" };
+					input_hints.push(HintInfo::keys([Key::KeyX], "Along X Axis"));
+					input_hints.push(HintInfo::keys([Key::KeyY], y_label));
+					if !local {
+						input_hints.push(HintInfo::keys([Key::KeyY, Key::KeyY], clear_constraint));
+					}
+				}
+			}
 		}
-		if matches!(self, TransformOperation::Grabbing(_) | TransformOperation::Scaling(_)) {
-			input_hints.push(HintInfo::keys([Key::KeyX], "Along X Axis"));
-			input_hints.push(HintInfo::keys([Key::KeyY], "Along Y Axis"));
+
+		let grs_hint_group = match self {
+			TransformOperation::None => unreachable!(),
+			TransformOperation::Scaling(_) => HintGroup(vec![HintInfo::multi_keys([[Key::KeyG], [Key::KeyR]], "Grab/Rotate Selected")]),
+			TransformOperation::Grabbing(_) => HintGroup(vec![HintInfo::multi_keys([[Key::KeyR], [Key::KeyS]], "Rotate/Scale Selected")]),
+			TransformOperation::Rotating(_) => HintGroup(vec![HintInfo::multi_keys([[Key::KeyG], [Key::KeyS]], "Grab/Scale Selected")]),
+		};
+
+		let mut hint_groups = vec![grs_hint_group];
+		if !self.is_typing() {
+			let modifiers = vec![HintInfo::keys([Key::Shift], "Slow"), HintInfo::keys([Key::Control], "Increments")];
+			hint_groups.push(HintGroup(modifiers));
 		}
+		hint_groups.push(HintGroup(input_hints));
 
-		let hint_data = HintData(vec![HintGroup(input_hints)]);
+		let hint_data = HintData(hint_groups);
 		responses.add(FrontendMessage::UpdateInputHints { hint_data });
 	}
 
@@ -369,6 +430,7 @@ impl TransformOperation {
 			*self = match self {
 				TransformOperation::Scaling(scale) => TransformOperation::Scaling(scale.negate()),
 				TransformOperation::Rotating(rotation) => TransformOperation::Rotating(rotation.negate()),
+				TransformOperation::Grabbing(translation) => TransformOperation::Grabbing(translation.negate()),
 				_ => *self,
 			};
 			self.apply_transform_operation(selected, snapping, local, quad, transform);
@@ -580,6 +642,7 @@ impl<'a> Selected<'a> {
 #[derive(Debug, Clone, PartialEq, Eq, Default)]
 pub struct Typing {
 	pub digits: Vec<u8>,
+	pub string: String,
 	pub contains_decimal: bool,
 	pub negative: bool,
 }
@@ -589,6 +652,7 @@ const DECIMAL_POINT: u8 = 10;
 impl Typing {
 	pub fn type_number(&mut self, number: u8) -> Option<f64> {
 		self.digits.push(number);
+		self.string.push((b'0' + number) as char);
 
 		self.evaluate()
 	}
@@ -603,7 +667,7 @@ impl Typing {
 			Some(_) => (),
 			None => self.negative = false,
 		}
-
+		self.string.pop();
 		self.evaluate()
 	}
 
@@ -611,6 +675,7 @@ impl Typing {
 		if !self.contains_decimal {
 			self.contains_decimal = true;
 			self.digits.push(DECIMAL_POINT);
+			self.string.push('.');
 		}
 
 		self.evaluate()
@@ -618,6 +683,11 @@ impl Typing {
 
 	pub fn type_negate(&mut self) -> Option<f64> {
 		self.negative = !self.negative;
+		if self.negative {
+			self.string.insert(0, '-');
+		} else {
+			self.string.remove(0);
+		}
 
 		self.evaluate()
 	}
@@ -653,6 +723,7 @@ impl Typing {
 
 	pub fn clear(&mut self) {
 		self.digits.clear();
+		self.string.clear();
 		self.contains_decimal = false;
 		self.negative = false;
 	}
diff --git a/editor/src/messages/tool/tool_messages/brush_tool.rs b/editor/src/messages/tool/tool_messages/brush_tool.rs
index 20dba81a83..3bc6d9099b 100644
--- a/editor/src/messages/tool/tool_messages/brush_tool.rs
+++ b/editor/src/messages/tool/tool_messages/brush_tool.rs
@@ -429,7 +429,7 @@ impl Fsm for BrushToolFsmState {
 		let hint_data = match self {
 			BrushToolFsmState::Ready => HintData(vec![
 				HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDrag, "Draw")]),
-				HintGroup(vec![HintInfo::keys([Key::BracketLeft, Key::BracketRight], "Shrink/Grow Brush")]),
+				HintGroup(vec![HintInfo::multi_keys([[Key::BracketLeft], [Key::BracketRight]], "Shrink/Grow Brush")]),
 			]),
 			BrushToolFsmState::Drawing => HintData(vec![HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()])]),
 		};
diff --git a/editor/src/messages/tool/tool_messages/path_tool.rs b/editor/src/messages/tool/tool_messages/path_tool.rs
index 029c5b1d53..e0252218ae 100644
--- a/editor/src/messages/tool/tool_messages/path_tool.rs
+++ b/editor/src/messages/tool/tool_messages/path_tool.rs
@@ -1055,7 +1055,7 @@ impl Fsm for PathToolFsmState {
 				HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDouble, "Make Anchor Smooth/Sharp")]),
 				// TODO: Only show the following hints if at least one point is selected
 				HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDrag, "Drag Selected")]),
-				HintGroup(vec![HintInfo::keys([Key::KeyG, Key::KeyR, Key::KeyS], "Grab/Rotate/Scale Selected")]),
+				HintGroup(vec![HintInfo::multi_keys([[Key::KeyG], [Key::KeyR], [Key::KeyS]], "Grab/Rotate/Scale Selected")]),
 				HintGroup(vec![HintInfo::arrow_keys("Nudge Selected"), HintInfo::keys([Key::Shift], "10x").prepend_plus()]),
 				HintGroup(vec![
 					HintInfo::keys([Key::Delete], "Delete Selected"),
diff --git a/editor/src/messages/tool/tool_messages/select_tool.rs b/editor/src/messages/tool/tool_messages/select_tool.rs
index 677acfaeda..c8fc8bf401 100644
--- a/editor/src/messages/tool/tool_messages/select_tool.rs
+++ b/editor/src/messages/tool/tool_messages/select_tool.rs
@@ -1198,7 +1198,7 @@ impl Fsm for SelectToolFsmState {
 						HintInfo::mouse(MouseMotion::LmbDrag, "Select Area"),
 						HintInfo::keys([Key::Shift], "Extend Selection").prepend_plus(),
 					]),
-					HintGroup(vec![HintInfo::keys([Key::KeyG, Key::KeyR, Key::KeyS], "Grab/Rotate/Scale Selected")]),
+					HintGroup(vec![HintInfo::multi_keys([[Key::KeyG], [Key::KeyR], [Key::KeyS]], "Grab/Rotate/Scale Selected")]),
 					HintGroup(vec![
 						HintInfo::arrow_keys("Nudge Selected"),
 						HintInfo::keys([Key::Shift], "10x").prepend_plus(),
diff --git a/editor/src/messages/tool/transform_layer/transform_layer_message.rs b/editor/src/messages/tool/transform_layer/transform_layer_message.rs
index 6163bf56a3..864eeb172d 100644
--- a/editor/src/messages/tool/transform_layer/transform_layer_message.rs
+++ b/editor/src/messages/tool/transform_layer/transform_layer_message.rs
@@ -7,6 +7,9 @@ use glam::DVec2;
 #[impl_message(Message, ToolMessage, TransformLayer)]
 #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
 pub enum TransformLayerMessage {
+	// Overlays
+	Overlays(OverlayContext),
+
 	// Messages
 	ApplyTransformOperation,
 	BeginGrab,
@@ -18,8 +21,7 @@ pub enum TransformLayerMessage {
 	CancelTransformOperation,
 	ConstrainX,
 	ConstrainY,
-	Overlays(OverlayContext),
-	PointerMove { slow_key: Key, snap_key: Key },
+	PointerMove { slow_key: Key, increments_key: Key },
 	SelectionChanged,
 	TypeBackspace,
 	TypeDecimalPoint,
diff --git a/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs b/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs
index 8f61600653..64c8590a5f 100644
--- a/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs
+++ b/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs
@@ -1,4 +1,4 @@
-use crate::consts::{ANGLE_MEASURE_RADIUS_FACTOR, ARC_MEASURE_RADIUS_FACTOR_RANGE, COLOR_OVERLAY_BLUE, COLOR_OVERLAY_LABEL_BACKGROUND, COLOR_OVERLAY_WHITE, SLOWING_DIVISOR};
+use crate::consts::{ANGLE_MEASURE_RADIUS_FACTOR, ARC_MEASURE_RADIUS_FACTOR_RANGE, COLOR_OVERLAY_BLUE, SLOWING_DIVISOR};
 use crate::messages::input_mapper::utility_types::input_mouse::ViewportPosition;
 use crate::messages::portfolio::document::overlays::utility_types::{OverlayProvider, Pivot};
 use crate::messages::portfolio::document::utility_types::transformation::{Axis, OriginalTransforms, Selected, TransformOperation, Typing};
@@ -16,12 +16,16 @@ use std::f64::consts::TAU;
 
 const TRANSFORM_GRS_OVERLAY_PROVIDER: OverlayProvider = |context| TransformLayerMessage::Overlays(context).into();
 
+// TODO: Get these from the input mapper
+const SLOW_KEY: Key = Key::Shift;
+const INCREMENTS_KEY: Key = Key::Control;
+
 #[derive(Debug, Clone, Default)]
 pub struct TransformLayerMessageHandler {
 	pub transform_operation: TransformOperation,
 
 	slow: bool,
-	snap: bool,
+	increments: bool,
 	local: bool,
 	fixed_bbox: Quad,
 	typing: Typing,
@@ -45,7 +49,7 @@ impl TransformLayerMessageHandler {
 	}
 
 	pub fn hints(&self, responses: &mut VecDeque<Message>) {
-		self.transform_operation.hints(responses);
+		self.transform_operation.hints(responses, self.local);
 	}
 }
 
@@ -74,6 +78,27 @@ fn calculate_pivot(selected_points: &Vec<&ManipulatorPointId>, vector_data: &Vec
 	}
 }
 
+fn project_edge_to_quad(edge: DVec2, quad: &Quad, local: bool, axis_constraint: Axis) -> DVec2 {
+	let quad = quad.0;
+	match axis_constraint {
+		Axis::X => {
+			if local {
+				edge.project_onto(quad[1] - quad[0])
+			} else {
+				edge.with_y(0.)
+			}
+		}
+		Axis::Y => {
+			if local {
+				edge.project_onto(quad[3] - quad[0])
+			} else {
+				edge.with_x(0.)
+			}
+		}
+		_ => edge,
+	}
+}
+
 type TransformData<'a> = (&'a DocumentMessageHandler, &'a InputPreprocessorMessageHandler, &'a ToolData, &'a mut ShapeState);
 impl MessageHandler<TransformLayerMessage, TransformData<'_>> for TransformLayerMessageHandler {
 	fn process_message(&mut self, message: TransformLayerMessage, responses: &mut VecDeque<Message>, (document, input, tool_data, shape_editor): TransformData) {
@@ -144,6 +169,121 @@ impl MessageHandler<TransformLayerMessage, TransformData<'_>> for TransformLayer
 		let document_to_viewport = document.metadata().document_to_viewport;
 
 		match message {
+			// Overlays
+			TransformLayerMessage::Overlays(mut overlay_context) => {
+				for layer in document.metadata().all_layers() {
+					if !document.network_interface.is_artboard(&layer.to_node(), &[]) {
+						continue;
+					};
+
+					let viewport_box = input.viewport_bounds.size();
+					let axis_constraint = match self.transform_operation {
+						TransformOperation::Grabbing(grabbing) => grabbing.constraint,
+						TransformOperation::Scaling(scaling) => scaling.constraint,
+						_ => Axis::Both,
+					};
+
+					let format_rounded = |value: f64, precision: usize| {
+						if self.typing.digits.is_empty() {
+							format!("{:.*}", precision, value).trim_end_matches('0').trim_end_matches('.').to_string()
+						} else {
+							self.typing.string.clone()
+						}
+					};
+
+					match self.transform_operation {
+						TransformOperation::None => (),
+						TransformOperation::Grabbing(translation) => {
+							let translation = translation.to_dvec(document_to_viewport, self.increments);
+							let viewport_translate = document_to_viewport.transform_vector2(translation);
+							let quad = Quad::from_box([self.grab_target, self.grab_target + viewport_translate]).0;
+							let e1 = (self.fixed_bbox.0[1] - self.fixed_bbox.0[0]).normalize();
+
+							if matches!(axis_constraint, Axis::Both | Axis::X) && translation.x != 0. {
+								let end = if self.local {
+									(quad[1] - quad[0]).length() * e1 * e1.dot(quad[1] - quad[0]).signum() + quad[0]
+								} else {
+									quad[1]
+								};
+								overlay_context.line(quad[0], end, None);
+
+								let x_transform = DAffine2::from_translation((quad[0] + end) / 2.);
+								overlay_context.text(&format_rounded(translation.x, 3), COLOR_OVERLAY_BLUE, None, x_transform, 4., [Pivot::Middle, Pivot::End]);
+							}
+
+							if matches!(axis_constraint, Axis::Both | Axis::Y) && translation.y != 0. {
+								let end = if self.local {
+									(quad[3] - quad[0]).length() * e1.perp() * e1.perp().dot(quad[3] - quad[0]).signum() + quad[0]
+								} else {
+									quad[3]
+								};
+								overlay_context.line(quad[0], end, None);
+								let x_parameter = viewport_translate.x.clamp(-1., 1.);
+								let y_transform = DAffine2::from_translation((quad[0] + end) / 2. + x_parameter * DVec2::X * 0.);
+								let pivot_selection = if x_parameter >= 0. { Pivot::Start } else { Pivot::End };
+								if axis_constraint != Axis::Both || self.typing.digits.is_empty() {
+									overlay_context.text(&format_rounded(translation.y, 2), COLOR_OVERLAY_BLUE, None, y_transform, 3., [pivot_selection, Pivot::Middle]);
+								}
+							}
+							if matches!(axis_constraint, Axis::Both) && translation.x != 0. && translation.y != 0. {
+								overlay_context.dashed_line(quad[1], quad[2], None, Some(2.), Some(2.), Some(0.5));
+								overlay_context.dashed_line(quad[3], quad[2], None, Some(2.), Some(2.), Some(0.5));
+							}
+						}
+						TransformOperation::Scaling(scale) => {
+							let to_mouse_final = self.mouse_position - self.pivot;
+							let to_mouse_start = self.start_mouse - self.pivot;
+
+							let to_mouse_final = project_edge_to_quad(to_mouse_final, &self.fixed_bbox, self.local, axis_constraint);
+							let to_mouse_start = project_edge_to_quad(to_mouse_start, &self.fixed_bbox, self.local, axis_constraint);
+
+							let scale = scale.to_f64(self.increments) * to_mouse_final.dot(to_mouse_start).signum();
+							let text = format!("{}x", format_rounded(scale, 3));
+							let local_edge = self.start_mouse - self.pivot;
+							let local_edge = project_edge_to_quad(local_edge, &self.fixed_bbox, self.local, axis_constraint);
+							let boundary_point = self.pivot + local_edge * scale.min(1.);
+							let end_point = self.pivot + local_edge * scale.max(1.);
+
+							if scale > 0. {
+								overlay_context.dashed_line(self.pivot, boundary_point, None, Some(4.), Some(4.), Some(0.5));
+							}
+							overlay_context.line(boundary_point, end_point, None);
+
+							let transform = DAffine2::from_translation(boundary_point.midpoint(self.pivot) + local_edge.perp().normalize() * local_edge.element_product().signum() * 24.);
+							overlay_context.text(&text, COLOR_OVERLAY_BLUE, None, transform, 16., [Pivot::Middle, Pivot::Middle]);
+						}
+						TransformOperation::Rotating(rotation) => {
+							let angle = rotation.to_f64(self.increments);
+							let quad = self.fixed_bbox.0;
+							let offset_angle = if self.grs_pen_handle {
+								self.handle - self.last_point
+							} else if using_path_tool {
+								self.start_mouse - self.pivot
+							} else {
+								quad[1] - quad[0]
+							};
+							let offset_angle = offset_angle.to_angle();
+							let width = viewport_box.max_element();
+							let radius = self.start_mouse.distance(self.pivot);
+							let arc_radius = ANGLE_MEASURE_RADIUS_FACTOR * width;
+							let radius = radius.clamp(ARC_MEASURE_RADIUS_FACTOR_RANGE.0 * width, ARC_MEASURE_RADIUS_FACTOR_RANGE.1 * width);
+							let text = format!("{}°", format_rounded(angle.to_degrees(), 2));
+							let text_texture_width = overlay_context.get_width(&text) / 2.;
+							let text_texture_height = 12.;
+							let text_angle_on_unit_circle = DVec2::from_angle((angle % TAU) / 2. + offset_angle);
+							let text_texture_position = DVec2::new(
+								(arc_radius + 4. + text_texture_width) * text_angle_on_unit_circle.x,
+								(arc_radius + text_texture_height) * text_angle_on_unit_circle.y,
+							);
+							let transform = DAffine2::from_translation(text_texture_position + self.pivot);
+							overlay_context.draw_angle(self.pivot, radius, arc_radius, offset_angle, angle);
+							overlay_context.text(&text, COLOR_OVERLAY_BLUE, None, transform, 16., [Pivot::Middle, Pivot::Middle]);
+						}
+					}
+				}
+			}
+
+			// Messages
 			TransformLayerMessage::ApplyTransformOperation => {
 				selected.original_transforms.clear();
 				self.typing.clear();
@@ -190,6 +330,11 @@ impl MessageHandler<TransformLayerMessage, TransformData<'_>> for TransformLayer
 				};
 
 				responses.add(OverlaysMessage::AddProvider(TRANSFORM_GRS_OVERLAY_PROVIDER));
+				// Find a way better than this hack
+				responses.add(TransformLayerMessage::PointerMove {
+					slow_key: SLOW_KEY,
+					increments_key: INCREMENTS_KEY,
+				});
 			}
 			TransformLayerMessage::BeginGrab => {
 				if (!using_path_tool && !using_select_tool && !using_pen_tool)
@@ -211,6 +356,10 @@ impl MessageHandler<TransformLayerMessage, TransformData<'_>> for TransformLayer
 				selected.original_transforms.clear();
 
 				responses.add(OverlaysMessage::AddProvider(TRANSFORM_GRS_OVERLAY_PROVIDER));
+				responses.add(TransformLayerMessage::PointerMove {
+					slow_key: SLOW_KEY,
+					increments_key: INCREMENTS_KEY,
+				});
 			}
 			TransformLayerMessage::BeginRotate => {
 				let selected_points: Vec<&ManipulatorPointId> = shape_editor.selected_points().collect();
@@ -261,6 +410,10 @@ impl MessageHandler<TransformLayerMessage, TransformData<'_>> for TransformLayer
 				selected.original_transforms.clear();
 
 				responses.add(OverlaysMessage::AddProvider(TRANSFORM_GRS_OVERLAY_PROVIDER));
+				responses.add(TransformLayerMessage::PointerMove {
+					slow_key: SLOW_KEY,
+					increments_key: INCREMENTS_KEY,
+				});
 			}
 			TransformLayerMessage::BeginScale => {
 				let selected_points: Vec<&ManipulatorPointId> = shape_editor.selected_points().collect();
@@ -310,6 +463,10 @@ impl MessageHandler<TransformLayerMessage, TransformData<'_>> for TransformLayer
 				selected.original_transforms.clear();
 
 				responses.add(OverlaysMessage::AddProvider(TRANSFORM_GRS_OVERLAY_PROVIDER));
+				responses.add(TransformLayerMessage::PointerMove {
+					slow_key: SLOW_KEY,
+					increments_key: INCREMENTS_KEY,
+				});
 			}
 			TransformLayerMessage::CancelTransformOperation => {
 				if using_pen_tool {
@@ -335,159 +492,21 @@ impl MessageHandler<TransformLayerMessage, TransformData<'_>> for TransformLayer
 			TransformLayerMessage::ConstrainX => {
 				self.local = self
 					.transform_operation
-					.constrain_axis(Axis::X, &mut selected, self.snap, self.local, self.fixed_bbox, document_to_viewport)
+					.constrain_axis(Axis::X, &mut selected, self.increments, self.local, self.fixed_bbox, document_to_viewport)
 			}
 			TransformLayerMessage::ConstrainY => {
 				self.local = self
 					.transform_operation
-					.constrain_axis(Axis::Y, &mut selected, self.snap, self.local, self.fixed_bbox, document_to_viewport)
+					.constrain_axis(Axis::Y, &mut selected, self.increments, self.local, self.fixed_bbox, document_to_viewport)
 			}
-			TransformLayerMessage::Overlays(mut overlay_context) => {
-				for layer in document.metadata().all_layers() {
-					if !document.network_interface.is_artboard(&layer.to_node(), &[]) {
-						continue;
-					};
-
-					let viewport_box = input.viewport_bounds.size();
-					let transform = DAffine2::from_translation(DVec2::new(0., viewport_box.y)) * DAffine2::from_scale(DVec2::splat(1.2));
-
-					let axis_constraint = match self.transform_operation {
-						TransformOperation::Grabbing(grabbing) => grabbing.constraint,
-						TransformOperation::Scaling(scaling) => scaling.constraint,
-						_ => Axis::Both,
-					};
-
-					let format_rounded = |value: f64, precision: usize| format!("{:.*}", precision, value).trim_end_matches('0').trim_end_matches('.').to_string();
-
-					let axis_text = |vector: DVec2, separate: bool| match (axis_constraint, separate) {
-						(Axis::Both, false) => format!("by {}", format_rounded(vector.x, 3)),
-						(Axis::Both, true) => format!("by ({}, {})", format_rounded(vector.x, 3), format_rounded(vector.y, 3)),
-						(Axis::X, _) => format!("X by {}", format_rounded(vector.x, 3)),
-						(Axis::Y, _) => format!("Y by {}", format_rounded(vector.y, 3)),
-					};
-
-					let grs_value_text = match self.transform_operation {
-						TransformOperation::None => String::new(),
-						TransformOperation::Grabbing(translation) => format!(
-							"Translating {}",
-							axis_text(document_to_viewport.inverse().transform_vector2(translation.to_dvec(document_to_viewport)), true)
-						),
-						TransformOperation::Rotating(rotation) => format!("Rotating by {}°", format_rounded(rotation.to_f64(self.snap).to_degrees(), 3)),
-						TransformOperation::Scaling(scale) => format!("Scaling {}", axis_text(scale.to_dvec(self.snap), false)),
-					};
-
-					match self.transform_operation {
-						TransformOperation::None => (),
-						TransformOperation::Grabbing(translation) => {
-							let translation = document_to_viewport.inverse().transform_vector2(translation.to_dvec(document_to_viewport));
-							let vec_to_end = self.mouse_position - self.start_mouse;
-							let quad = Quad::from_box([self.grab_target, self.grab_target + vec_to_end]).0;
-							let e1 = (self.fixed_bbox.0[1] - self.fixed_bbox.0[0]).normalize();
-
-							if matches!(axis_constraint, Axis::Both | Axis::X) {
-								let end = if self.local {
-									(quad[1] - quad[0]).length() * e1 * e1.dot(quad[1] - quad[0]).signum() + quad[0]
-								} else {
-									quad[1]
-								};
-								overlay_context.line(quad[0], end, None);
-
-								let x_transform = DAffine2::from_translation((quad[0] + end) / 2.);
-								overlay_context.text(&format_rounded(translation.x, 3), COLOR_OVERLAY_BLUE, None, x_transform, 4., [Pivot::Middle, Pivot::End]);
-							}
-
-							if matches!(axis_constraint, Axis::Both | Axis::Y) {
-								let end = if self.local {
-									(quad[3] - quad[0]).length() * e1.perp() * e1.perp().dot(quad[3] - quad[0]).signum() + quad[0]
-								} else {
-									quad[3]
-								};
-								overlay_context.line(quad[0], end, None);
-								let x_parameter = vec_to_end.x.clamp(-1., 1.);
-								let y_transform = DAffine2::from_translation((quad[0] + end) / 2. + x_parameter * DVec2::X * 0.);
-								let pivot_selection = if x_parameter > 0. {
-									Pivot::Start
-								} else if x_parameter == 0. {
-									Pivot::Middle
-								} else {
-									Pivot::End
-								};
-								overlay_context.text(&format_rounded(translation.y, 2), COLOR_OVERLAY_BLUE, None, y_transform, 3., [pivot_selection, Pivot::Middle]);
-							}
-							if matches!(axis_constraint, Axis::Both) {
-								overlay_context.dashed_line(quad[1], quad[2], None, Some(2.), Some(2.), Some(0.5));
-								overlay_context.dashed_line(quad[3], quad[2], None, Some(2.), Some(2.), Some(0.5));
-							}
-						}
-						TransformOperation::Scaling(scale) => {
-							let scale = scale.to_f64(self.snap);
-							let text = format!("{}x", format_rounded(scale, 3));
-							let extension_vector = self.mouse_position - self.start_mouse;
-							let local_edge = self.start_mouse - self.pivot;
-							let quad = self.fixed_bbox.0;
-							let local_edge = match axis_constraint {
-								Axis::X => {
-									if self.local {
-										local_edge.project_onto(quad[1] - quad[0])
-									} else {
-										local_edge.with_y(0.)
-									}
-								}
-								Axis::Y => {
-									if self.local {
-										local_edge.project_onto(quad[3] - quad[0])
-									} else {
-										local_edge.with_x(0.)
-									}
-								}
-								_ => local_edge,
-							};
-							let boundary_point = local_edge + self.pivot;
-							let projected_pointer = extension_vector.project_onto(local_edge);
-							let dashed_till = if extension_vector.dot(local_edge) < 0. { local_edge + projected_pointer } else { local_edge };
-							let lined_till = projected_pointer + boundary_point;
-							if dashed_till.dot(local_edge) > 0. {
-								overlay_context.dashed_line(self.pivot, self.pivot + dashed_till, None, Some(4.), Some(4.), Some(0.5));
-							}
-							overlay_context.line(boundary_point, lined_till, None);
-
-							let transform = DAffine2::from_translation(boundary_point.midpoint(self.pivot) + local_edge.perp().normalize() * local_edge.element_product().signum() * 24.);
-							overlay_context.text(&text, COLOR_OVERLAY_BLUE, None, transform, 16., [Pivot::Middle, Pivot::Middle]);
-						}
-						TransformOperation::Rotating(rotation) => {
-							let angle = rotation.to_f64(self.snap);
-							let quad = self.fixed_bbox.0;
-							let offset_angle = if self.grs_pen_handle { self.handle - self.last_point } else { quad[1] - quad[0] };
-							let offset_angle = offset_angle.to_angle();
-							let width = viewport_box.max_element();
-							let radius = self.start_mouse.distance(self.pivot);
-							let arc_radius = ANGLE_MEASURE_RADIUS_FACTOR * width;
-							let radius = radius.clamp(ARC_MEASURE_RADIUS_FACTOR_RANGE.0 * width, ARC_MEASURE_RADIUS_FACTOR_RANGE.1 * width);
-							let text = format!("{}°", format_rounded(angle.to_degrees(), 2));
-							let text_texture_width = overlay_context.get_width(&text) / 2.;
-							let text_texture_height = 12.;
-							let text_angle_on_unit_circle = DVec2::from_angle((angle % TAU) / 2. + offset_angle);
-							let text_texture_position = DVec2::new(
-								(arc_radius + 4. + text_texture_width) * text_angle_on_unit_circle.x,
-								(arc_radius + text_texture_height) * text_angle_on_unit_circle.y,
-							);
-							let transform = DAffine2::from_translation(text_texture_position + self.pivot);
-							overlay_context.draw_angle(self.pivot, radius, arc_radius, offset_angle, angle);
-							overlay_context.text(&text, COLOR_OVERLAY_BLUE, None, transform, 16., [Pivot::Middle, Pivot::Middle]);
-						}
-					}
-
-					overlay_context.text(&grs_value_text, COLOR_OVERLAY_WHITE, Some(COLOR_OVERLAY_LABEL_BACKGROUND), transform, 4., [Pivot::Start, Pivot::End]);
-				}
-			}
-			TransformLayerMessage::PointerMove { slow_key, snap_key } => {
+			TransformLayerMessage::PointerMove { slow_key, increments_key } => {
 				self.slow = input.keyboard.get(slow_key as usize);
 
-				let new_snap = input.keyboard.get(snap_key as usize);
-				if new_snap != self.snap {
-					self.snap = new_snap;
+				let new_increments = input.keyboard.get(increments_key as usize);
+				if new_increments != self.increments {
+					self.increments = new_increments;
 					self.transform_operation
-						.apply_transform_operation(&mut selected, self.snap, self.local, self.fixed_bbox, document_to_viewport);
+						.apply_transform_operation(&mut selected, self.increments, self.local, self.fixed_bbox, document_to_viewport);
 				}
 
 				if self.typing.digits.is_empty() {
@@ -499,7 +518,7 @@ impl MessageHandler<TransformLayerMessage, TransformData<'_>> for TransformLayer
 							let change = if self.slow { delta_pos / SLOWING_DIVISOR } else { delta_pos };
 							self.transform_operation = TransformOperation::Grabbing(translation.increment_amount(change));
 							self.transform_operation
-								.apply_transform_operation(&mut selected, self.snap, self.local, self.fixed_bbox, document_to_viewport);
+								.apply_transform_operation(&mut selected, self.increments, self.local, self.fixed_bbox, document_to_viewport);
 						}
 						TransformOperation::Rotating(rotation) => {
 							let start_offset = *selected.pivot - self.mouse_position;
@@ -510,22 +529,33 @@ impl MessageHandler<TransformLayerMessage, TransformData<'_>> for TransformLayer
 
 							self.transform_operation = TransformOperation::Rotating(rotation.increment_amount(change));
 							self.transform_operation
-								.apply_transform_operation(&mut selected, self.snap, self.local, self.fixed_bbox, document_to_viewport);
+								.apply_transform_operation(&mut selected, self.increments, self.local, self.fixed_bbox, document_to_viewport);
 						}
 						TransformOperation::Scaling(scale) => {
+							let axis_constraint = scale.constraint;
+							let to_mouse_final = self.mouse_position - *selected.pivot;
+							let to_mouse_final_old = input.mouse.position - *selected.pivot;
+							let to_mouse_start = self.start_mouse - *selected.pivot;
+
+							let to_mouse_final = project_edge_to_quad(to_mouse_final, &self.fixed_bbox, self.local, axis_constraint);
+							let to_mouse_final_old = project_edge_to_quad(to_mouse_final_old, &self.fixed_bbox, self.local, axis_constraint);
+							let to_mouse_start = project_edge_to_quad(to_mouse_start, &self.fixed_bbox, self.local, axis_constraint);
+
 							let change = {
-								let previous_frame_dist = (self.mouse_position - *selected.pivot).length();
-								let current_frame_dist = (input.mouse.position - *selected.pivot).length();
-								let start_transform_dist = (self.start_mouse - *selected.pivot).length();
+								let previous_frame_dist = to_mouse_final.length();
+								let current_frame_dist = to_mouse_final_old.length();
+								let start_transform_dist = to_mouse_start.length();
 
 								(current_frame_dist - previous_frame_dist) / start_transform_dist
 							};
 							let change = if self.slow { change / SLOWING_DIVISOR } else { change };
 
-							self.transform_operation = TransformOperation::Scaling(scale.increment_amount(change));
+							let sign = to_mouse_final.dot(to_mouse_start).signum();
+							let scale = scale.increment_amount(change * scale.to_f64(self.increments).signum());
+							self.transform_operation = TransformOperation::Scaling(scale);
 
-							self.transform_operation
-								.apply_transform_operation(&mut selected, self.snap, self.local, self.fixed_bbox, document_to_viewport);
+							let op = TransformOperation::Scaling(if sign > 0. { scale } else { scale.negate() });
+							op.apply_transform_operation(&mut selected, self.increments, self.local, self.fixed_bbox, document_to_viewport);
 						}
 					};
 				}
@@ -536,23 +566,32 @@ impl MessageHandler<TransformLayerMessage, TransformData<'_>> for TransformLayer
 				let target_layers = document.network_interface.selected_nodes(&[]).unwrap().selected_layers(document.metadata()).collect();
 				shape_editor.set_selected_layers(target_layers);
 			}
-			TransformLayerMessage::TypeBackspace => self
-				.transform_operation
-				.grs_typed(self.typing.type_backspace(), &mut selected, self.snap, self.local, self.fixed_bbox, document_to_viewport),
-			TransformLayerMessage::TypeDecimalPoint => {
+			TransformLayerMessage::TypeBackspace => {
+				if self.typing.digits.is_empty() && self.typing.negative {
+					self.transform_operation.negate(&mut selected, self.increments, self.local, self.fixed_bbox, document_to_viewport);
+					self.typing.type_negate();
+				}
 				self.transform_operation
-					.grs_typed(self.typing.type_decimal_point(), &mut selected, self.snap, self.local, self.fixed_bbox, document_to_viewport)
+					.grs_typed(self.typing.type_backspace(), &mut selected, self.increments, self.local, self.fixed_bbox, document_to_viewport);
+			}
+			TransformLayerMessage::TypeDecimalPoint => {
+				if self.typing.digits.is_empty() {
+					self.typing.negative = false;
+				} else {
+					self.transform_operation
+						.grs_typed(self.typing.type_decimal_point(), &mut selected, self.increments, self.local, self.fixed_bbox, document_to_viewport)
+				}
 			}
 			TransformLayerMessage::TypeDigit { digit } => {
 				self.transform_operation
-					.grs_typed(self.typing.type_number(digit), &mut selected, self.snap, self.local, self.fixed_bbox, document_to_viewport)
+					.grs_typed(self.typing.type_number(digit), &mut selected, self.increments, self.local, self.fixed_bbox, document_to_viewport)
 			}
 			TransformLayerMessage::TypeNegate => {
 				if self.typing.digits.is_empty() {
-					self.transform_operation.negate(&mut selected, self.snap, self.local, self.fixed_bbox, document_to_viewport);
+					self.transform_operation.negate(&mut selected, self.increments, self.local, self.fixed_bbox, document_to_viewport);
 				}
 				self.transform_operation
-					.grs_typed(self.typing.type_negate(), &mut selected, self.snap, self.local, self.fixed_bbox, document_to_viewport)
+					.grs_typed(self.typing.type_negate(), &mut selected, self.increments, self.local, self.fixed_bbox, document_to_viewport)
 			}
 		}
 	}
diff --git a/editor/src/messages/tool/utility_types.rs b/editor/src/messages/tool/utility_types.rs
index 37d3e21911..7d4567d726 100644
--- a/editor/src/messages/tool/utility_types.rs
+++ b/editor/src/messages/tool/utility_types.rs
@@ -482,8 +482,12 @@ pub struct HintInfo {
 }
 
 impl HintInfo {
+	/// Used for a hint where a single key or key stroke is used to perform one action.
+	/// Examples:
+	/// - The Escape key can be used to cancel an action
+	/// - The Ctrl+C key stroke can be used to copy
 	pub fn keys(keys: impl IntoIterator<Item = Key>, label: impl Into<Cow<'static, str>>) -> Self {
-		let keys: Vec<_> = keys.into_iter().collect();
+		let keys = keys.into_iter().collect();
 		Self {
 			key_groups: vec![KeysGroup(keys).into()],
 			key_groups_mac: None,
@@ -494,6 +498,10 @@ impl HintInfo {
 		}
 	}
 
+	/// Used for a hint where multiple different individual keys can be used to perform variations of the same action. These keys are represented with a slight separation between them compared to [`Self::keys`].
+	/// Examples:
+	/// - The four arrow keys can be used to nudge a layer in different directions
+	/// - The G, R, and S keys can be used to enter GRS transformation mode
 	pub fn multi_keys(multi_keys: impl IntoIterator<Item = impl IntoIterator<Item = Key>>, label: impl Into<Cow<'static, str>>) -> Self {
 		let key_groups = multi_keys.into_iter().map(|keys| KeysGroup(keys.into_iter().collect()).into()).collect();
 		Self {
@@ -529,7 +537,7 @@ impl HintInfo {
 	}
 
 	pub fn keys_and_mouse(keys: impl IntoIterator<Item = Key>, mouse_motion: MouseMotion, label: impl Into<Cow<'static, str>>) -> Self {
-		let keys: Vec<_> = keys.into_iter().collect();
+		let keys = keys.into_iter().collect();
 		Self {
 			key_groups: vec![KeysGroup(keys).into()],
 			key_groups_mac: None,
@@ -568,7 +576,7 @@ impl HintInfo {
 	}
 
 	pub fn add_mac_keys(mut self, keys: impl IntoIterator<Item = Key>) -> Self {
-		let mac_keys: Vec<_> = keys.into_iter().collect();
+		let mac_keys = keys.into_iter().collect();
 		self.key_groups_mac = Some(vec![KeysGroup(mac_keys).into()]);
 		self
 	}


Test patch: