Repository: GraphiteEditor/Graphite (language: rust)

Issue:
Transform cage skew sliding triangle gizmos
![Image](https://github.com/user-attachments/assets/25d33d82-b7d2-4a36-8b36-4675927e40e5)

As of #2251, you can currently skew by holding Ctrl when dragging an edge of the transform cage. I'd like to add these triangles that appear when hovering over an edge's resize click target. Only the edge for which you're hovering should show the pair of triangles. Triangles are the same dimensions as the squares (7 x 7 px), but with a vertex at `7 / 2 = 3.5` instead of the square's corners. They are located 4 px away from the square. After adding this, we can remove the `Ctrl` shortcut which isn't needed anymore since this is easier and more discoverable. However, when dragging a triangle, `Ctrl` can be assigned to allow the dragging of the entire edge instead of just sliding it. Meaning it's allowed to follow the cursor along both the local X and local Y axis, not just sliding along the local X or Y axis (whichever edge is being dragged). If `Ctrl` is released while dragging, it should return to its previous sliding-only position, as if the user had never held `Ctrl` but merely moved the cursor around, only sliding it along the local axis. The hints, during the skew sliding, should indicate that `Ctrl` unlocks the slide.


Discussion / hints:

Fix patch:
diff --git a/editor/src/consts.rs b/editor/src/consts.rs
index c457fda986..b5959ef9ec 100644
--- a/editor/src/consts.rs
+++ b/editor/src/consts.rs
@@ -88,6 +88,11 @@ pub const MIN_LENGTH_FOR_RESIZE_TO_INCLUDE_INTERIOR: f64 = 40.;
 /// The motion of the user's cursor by an `x` pixel offset results in `x * scale_factor` pixels of offset on the other side.
 pub const MAXIMUM_ALT_SCALE_FACTOR: f64 = 25.;
 
+// SKEW TRIANGLES
+pub const SKEW_TRIANGLE_SIZE: f64 = 7.;
+pub const SKEW_TRIANGLE_OFFSET: f64 = 4.;
+pub const MIN_LENGTH_FOR_SKEW_TRIANGLE_VISIBILITY: f64 = 48.;
+
 // PATH TOOL
 pub const MANIPULATOR_GROUP_MARKER_SIZE: f64 = 6.;
 pub const SELECTION_THRESHOLD: f64 = 10.;
diff --git a/editor/src/messages/portfolio/document/overlays/utility_types.rs b/editor/src/messages/portfolio/document/overlays/utility_types.rs
index 125a1ed091..3d29f4d15a 100644
--- a/editor/src/messages/portfolio/document/overlays/utility_types.rs
+++ b/editor/src/messages/portfolio/document/overlays/utility_types.rs
@@ -42,6 +42,30 @@ impl OverlayContext {
 		self.dashed_polygon(&quad.0, color_fill, None, None, None);
 	}
 
+	pub fn draw_triangle(&mut self, base: DVec2, direction: DVec2, size: f64, color_fill: Option<&str>, color_stroke: Option<&str>) {
+		let color_fill = color_fill.unwrap_or(COLOR_OVERLAY_WHITE);
+		let color_stroke = color_stroke.unwrap_or(COLOR_OVERLAY_BLUE);
+		let normal = direction.perp();
+		let top = base + direction * size;
+		let edge1 = base + normal * size / 2.;
+		let edge2 = base - normal * size / 2.;
+
+		self.start_dpi_aware_transform();
+
+		self.render_context.begin_path();
+		self.render_context.move_to(top.x, top.y);
+		self.render_context.line_to(edge1.x, edge1.y);
+		self.render_context.line_to(edge2.x, edge2.y);
+		self.render_context.close_path();
+
+		self.render_context.set_fill_style_str(color_fill);
+		self.render_context.set_stroke_style_str(color_stroke);
+		self.render_context.fill();
+		self.render_context.stroke();
+
+		self.end_dpi_aware_transform();
+	}
+
 	pub fn dashed_quad(&mut self, quad: Quad, color_fill: Option<&str>, dash_width: Option<f64>, dash_gap_width: Option<f64>, dash_offset: Option<f64>) {
 		self.dashed_polygon(&quad.0, color_fill, dash_width, dash_gap_width, dash_offset);
 	}
diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs
index da0f676762..f94f3716a6 100644
--- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs
+++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs
@@ -5334,7 +5334,7 @@ impl NodeNetworkInterface {
 		// If a non artboard layer is attempted to be connected to the exports, and there is already an artboard connected, then connect the layer to the artboard.
 		if let Some(first_layer) = LayerNodeIdentifier::ROOT_PARENT.children(&self.document_metadata).next() {
 			if parent == LayerNodeIdentifier::ROOT_PARENT
-				&& !self.reference(&layer.to_node(), network_path).is_some_and(|reference| *reference == Some("Artboard".to_string()))
+				&& self.reference(&layer.to_node(), network_path).is_none_or(|reference| *reference != Some("Artboard".to_string()))
 				&& self.is_artboard(&first_layer.to_node(), network_path)
 			{
 				parent = first_layer;
diff --git a/editor/src/messages/portfolio/document/utility_types/transformation.rs b/editor/src/messages/portfolio/document/utility_types/transformation.rs
index 24953b9dec..88e3b42ea2 100644
--- a/editor/src/messages/portfolio/document/utility_types/transformation.rs
+++ b/editor/src/messages/portfolio/document/utility_types/transformation.rs
@@ -306,6 +306,7 @@ pub enum TransformOperation {
 }
 
 impl TransformOperation {
+	#[allow(clippy::too_many_arguments)]
 	pub fn apply_transform_operation(&self, selected: &mut Selected, increment_mode: bool, local: bool, quad: Quad, transform: DAffine2, pivot: DVec2, local_transform: DAffine2) {
 		let local_axis_transform_angle = (quad.top_left() - quad.top_right()).to_angle();
 		if self != &TransformOperation::None {
@@ -351,6 +352,7 @@ impl TransformOperation {
 		self.is_constraint_to_axis() || !matches!(self, TransformOperation::Grabbing(_))
 	}
 
+	#[allow(clippy::too_many_arguments)]
 	pub fn constrain_axis(&mut self, axis: Axis, selected: &mut Selected, increment_mode: bool, mut local: bool, quad: Quad, transform: DAffine2, pivot: DVec2, local_transform: DAffine2) -> bool {
 		(*self, local) = match self {
 			TransformOperation::Grabbing(translation) => {
@@ -367,6 +369,7 @@ impl TransformOperation {
 		local
 	}
 
+	#[allow(clippy::too_many_arguments)]
 	pub fn grs_typed(&mut self, typed: Option<f64>, selected: &mut Selected, increment_mode: bool, local: bool, quad: Quad, transform: DAffine2, pivot: DVec2, local_transform: DAffine2) {
 		match self {
 			TransformOperation::None => (),
@@ -457,6 +460,7 @@ impl TransformOperation {
 		}
 	}
 
+	#[allow(clippy::too_many_arguments)]
 	pub fn negate(&mut self, selected: &mut Selected, increment_mode: bool, local: bool, quad: Quad, transform: DAffine2, pivot: DVec2, local_transform: DAffine2) {
 		if *self != TransformOperation::None {
 			*self = match self {
diff --git a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs
index c024ecab41..82f14d0a26 100644
--- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs
+++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs
@@ -48,15 +48,12 @@ pub fn merge_layers(document: &DocumentMessageHandler, first_layer: LayerNodeIde
 
 	let mut current_and_other_layer_is_spline = false;
 
-	match (find_spline(document, first_layer), find_spline(document, second_layer)) {
-		(Some(current_layer_spline), Some(other_layer_spline)) => {
-			responses.add(NodeGraphMessage::DeleteNodes {
-				node_ids: [current_layer_spline, other_layer_spline].to_vec(),
-				delete_children: false,
-			});
-			current_and_other_layer_is_spline = true;
-		}
-		_ => {}
+	if let (Some(current_layer_spline), Some(other_layer_spline)) = (find_spline(document, first_layer), find_spline(document, second_layer)) {
+		responses.add(NodeGraphMessage::DeleteNodes {
+			node_ids: [current_layer_spline, other_layer_spline].to_vec(),
+			delete_children: false,
+		});
+		current_and_other_layer_is_spline = true;
 	}
 
 	// Move the `second_layer` below the `first_layer` for positioning purposes
diff --git a/editor/src/messages/tool/common_functionality/snapping.rs b/editor/src/messages/tool/common_functionality/snapping.rs
index e20e02c343..ffc96d230a 100644
--- a/editor/src/messages/tool/common_functionality/snapping.rs
+++ b/editor/src/messages/tool/common_functionality/snapping.rs
@@ -324,10 +324,10 @@ impl SnapManager {
 		let layer_bounds = document.metadata().transform_to_document(layer) * Quad::from_box(bounds);
 		let screen_bounds = document.metadata().document_to_viewport.inverse() * Quad::from_box([DVec2::ZERO, snap_data.input.viewport_bounds.size()]);
 		if screen_bounds.intersects(layer_bounds) {
-			if !self.alignment_candidates.as_ref().is_some_and(|candidates| candidates.len() > 100) {
+			if self.alignment_candidates.as_ref().is_none_or(|candidates| candidates.len() <= 100) {
 				self.alignment_candidates.get_or_insert_with(Vec::new).push(layer);
 			}
-			if quad.intersects(layer_bounds) && !self.candidates.as_ref().is_some_and(|candidates| candidates.len() > 10) {
+			if quad.intersects(layer_bounds) && self.candidates.as_ref().is_none_or(|candidates| candidates.len() <= 10) {
 				self.candidates.get_or_insert_with(Vec::new).push(layer);
 			}
 		}
diff --git a/editor/src/messages/tool/common_functionality/transformation_cage.rs b/editor/src/messages/tool/common_functionality/transformation_cage.rs
index cddef22722..45a120a930 100644
--- a/editor/src/messages/tool/common_functionality/transformation_cage.rs
+++ b/editor/src/messages/tool/common_functionality/transformation_cage.rs
@@ -1,6 +1,7 @@
 use crate::consts::{
 	BOUNDS_ROTATE_THRESHOLD, BOUNDS_SELECT_THRESHOLD, COLOR_OVERLAY_WHITE, MAXIMUM_ALT_SCALE_FACTOR, MIN_LENGTH_FOR_CORNERS_VISIBILITY, MIN_LENGTH_FOR_EDGE_RESIZE_PRIORITY_OVER_CORNERS,
-	MIN_LENGTH_FOR_MIDPOINT_VISIBILITY, MIN_LENGTH_FOR_RESIZE_TO_INCLUDE_INTERIOR, RESIZE_HANDLE_SIZE, SELECTION_DRAG_ANGLE,
+	MIN_LENGTH_FOR_MIDPOINT_VISIBILITY, MIN_LENGTH_FOR_RESIZE_TO_INCLUDE_INTERIOR, MIN_LENGTH_FOR_SKEW_TRIANGLE_VISIBILITY, RESIZE_HANDLE_SIZE, SELECTION_DRAG_ANGLE, SKEW_TRIANGLE_OFFSET,
+	SKEW_TRIANGLE_SIZE,
 };
 use crate::messages::frontend::utility_types::MouseCursorIcon;
 use crate::messages::portfolio::document::overlays::utility_types::OverlayContext;
@@ -15,6 +16,9 @@ use glam::{DAffine2, DMat2, DVec2};
 
 use super::snapping::{self, SnapCandidatePoint, SnapConstraint, SnapData, SnapManager, SnappedPoint};
 
+/// (top, bottom, left, right)
+pub type EdgeBool = (bool, bool, bool, bool);
+
 pub struct SizeSnapData<'a> {
 	pub manager: &'a mut SnapManager,
 	pub points: &'a mut Vec<SnapCandidatePoint>,
@@ -250,7 +254,8 @@ impl SelectedEdges {
 		(DAffine2::from_scale(enlargement_factor), pivot)
 	}
 
-	pub fn skew_transform(&self, mouse: DVec2, to_viewport_transform: DAffine2) -> DAffine2 {
+	// TODO: Add free movement when Ctrl is pressed to allow dragging the whole edge, not just sliding it
+	pub fn skew_transform(&self, mouse: DVec2, to_viewport_transform: DAffine2, _free_movement: bool) -> DAffine2 {
 		// Skip if the matrix is singular (as it isn't really possible to skew).
 		if !to_viewport_transform.matrix2.determinant().recip().is_finite() {
 			return DAffine2::IDENTITY;
@@ -377,6 +382,171 @@ impl BoundingBoxManager {
 		]
 	}
 
+	pub fn get_closest_edge(&self, edges: EdgeBool, cursor: DVec2) -> EdgeBool {
+		if !edges.0 && !edges.1 && !edges.2 && !edges.3 {
+			return (false, false, false, false);
+		}
+
+		let cursor = self.transform.inverse().transform_point2(cursor);
+		let min = self.bounds[0].min(self.bounds[1]);
+		let max = self.bounds[0].max(self.bounds[1]);
+
+		let distances = [
+			edges.0.then(|| (cursor - DVec2::new(cursor.x, min.y)).length_squared()),
+			edges.1.then(|| (cursor - DVec2::new(cursor.x, max.y)).length_squared()),
+			edges.2.then(|| (cursor - DVec2::new(min.x, cursor.y)).length_squared()),
+			edges.3.then(|| (cursor - DVec2::new(max.x, cursor.y)).length_squared()),
+		];
+
+		let min_distance = distances.iter().filter_map(|&x| x).min_by(|a, b| a.partial_cmp(b).unwrap());
+
+		match min_distance {
+			Some(min) => (
+				edges.0 && distances[0].is_some_and(|d| (d - min).abs() < f64::EPSILON),
+				edges.1 && distances[1].is_some_and(|d| (d - min).abs() < f64::EPSILON),
+				edges.2 && distances[2].is_some_and(|d| (d - min).abs() < f64::EPSILON),
+				edges.3 && distances[3].is_some_and(|d| (d - min).abs() < f64::EPSILON),
+			),
+			None => (false, false, false, false),
+		}
+	}
+
+	pub fn check_skew_handle(&self, cursor: DVec2, edge: EdgeBool) -> bool {
+		if let Some([start, end]) = self.edge_endpoints_vector_from_edge_bool(edge) {
+			if (end - start).length() < MIN_LENGTH_FOR_SKEW_TRIANGLE_VISIBILITY {
+				return false;
+			}
+
+			let touches_triangle = |base: DVec2, direction: DVec2, cursor: DVec2| -> bool {
+				let normal = direction.perp();
+				let top = base + direction * SKEW_TRIANGLE_SIZE;
+				let edge1 = base + normal * SKEW_TRIANGLE_SIZE / 2.;
+				let edge2 = base - normal * SKEW_TRIANGLE_SIZE / 2.;
+
+				let v0 = edge1 - top;
+				let v1 = edge2 - top;
+				let v2 = cursor - top;
+
+				let d00 = v0.dot(v0);
+				let d01 = v0.dot(v1);
+				let d11 = v1.dot(v1);
+				let d20 = v2.dot(v0);
+				let d21 = v2.dot(v1);
+
+				let denom = d00 * d11 - d01 * d01;
+				let v = (d11 * d20 - d01 * d21) / denom;
+				let w = (d00 * d21 - d01 * d20) / denom;
+				let u = 1. - v - w;
+
+				u >= 0. && v >= 0. && w >= 0.
+			};
+
+			let edge_dir = (end - start).normalize();
+			let mid = end.midpoint(start);
+
+			for direction in [edge_dir, -edge_dir] {
+				let base = mid + direction * (3. + SKEW_TRIANGLE_OFFSET);
+				if touches_triangle(base, direction, cursor) {
+					return true;
+				}
+			}
+		}
+
+		false
+	}
+
+	pub fn edge_endpoints_vector_from_edge_bool(&self, edges: EdgeBool) -> Option<[DVec2; 2]> {
+		let quad = self.transform * Quad::from_box(self.bounds);
+		let category = self.overlay_display_category();
+
+		if matches!(
+			category,
+			TransformCageSizeCategory::Full | TransformCageSizeCategory::Narrow | TransformCageSizeCategory::ReducedLandscape
+		) {
+			if edges.0 {
+				return Some([quad.top_left(), quad.top_right()]);
+			}
+			if edges.1 {
+				return Some([quad.bottom_left(), quad.bottom_right()]);
+			}
+		}
+
+		if matches!(
+			category,
+			TransformCageSizeCategory::Full | TransformCageSizeCategory::Narrow | TransformCageSizeCategory::ReducedPortrait
+		) {
+			if edges.2 {
+				return Some([quad.top_left(), quad.bottom_left()]);
+			}
+			if edges.3 {
+				return Some([quad.top_right(), quad.bottom_right()]);
+			}
+		}
+		None
+	}
+
+	pub fn render_skew_gizmos(&mut self, overlay_context: &mut OverlayContext, hover_edge: EdgeBool) {
+		let mut draw_edge_triangles = |start: DVec2, end: DVec2| {
+			if (end - start).length() < MIN_LENGTH_FOR_SKEW_TRIANGLE_VISIBILITY {
+				return;
+			}
+
+			let edge_dir = (end - start).normalize();
+			let mid = end.midpoint(start);
+
+			for edge in [edge_dir, -edge_dir] {
+				overlay_context.draw_triangle(mid + edge * (3. + SKEW_TRIANGLE_OFFSET), edge, SKEW_TRIANGLE_SIZE, None, None);
+			}
+		};
+
+		if let Some([start, end]) = self.edge_endpoints_vector_from_edge_bool(hover_edge) {
+			draw_edge_triangles(start, end);
+		}
+	}
+
+	pub fn over_extended_edge_midpoint(&self, mouse: DVec2, hover_edge: EdgeBool) -> bool {
+		const HALF_WIDTH_OUTER_RECT: f64 = RESIZE_HANDLE_SIZE / 2. + SKEW_TRIANGLE_OFFSET + SKEW_TRIANGLE_SIZE;
+		const HALF_WIDTH_INNER_RECT: f64 = SKEW_TRIANGLE_OFFSET + RESIZE_HANDLE_SIZE / 2.;
+
+		const INNER_QUAD_CORNER: DVec2 = DVec2::new(HALF_WIDTH_INNER_RECT, RESIZE_HANDLE_SIZE / 2.);
+		const FULL_QUAD_CORNER: DVec2 = DVec2::new(HALF_WIDTH_OUTER_RECT, BOUNDS_SELECT_THRESHOLD);
+
+		let quad = self.transform * Quad::from_box(self.bounds);
+
+		let Some([start, end]) = self.edge_endpoints_vector_from_edge_bool(hover_edge) else {
+			return false;
+		};
+		if (end - start).length() < MIN_LENGTH_FOR_SKEW_TRIANGLE_VISIBILITY {
+			return false;
+		}
+
+		let angle;
+		let is_compact;
+		if hover_edge.0 || hover_edge.1 {
+			angle = (quad.top_left() - quad.top_right()).to_angle();
+			is_compact = (quad.top_left() - quad.bottom_left()).length_squared() < MIN_LENGTH_FOR_RESIZE_TO_INCLUDE_INTERIOR.powi(2);
+		} else if hover_edge.2 || hover_edge.3 {
+			angle = (quad.top_left() - quad.bottom_left()).to_angle();
+			is_compact = (quad.top_left() - quad.top_right()).length_squared() < MIN_LENGTH_FOR_RESIZE_TO_INCLUDE_INTERIOR.powi(2);
+		} else {
+			return false;
+		};
+
+		let has_triangle_hover = self.check_skew_handle(mouse, hover_edge);
+		let point = start.midpoint(end);
+
+		if is_compact {
+			let upper_rect = DAffine2::from_angle_translation(angle, point) * Quad::from_box([-FULL_QUAD_CORNER.with_y(0.), FULL_QUAD_CORNER]);
+			let inter_triangle_quad = DAffine2::from_angle_translation(angle, point) * Quad::from_box([-INNER_QUAD_CORNER, INNER_QUAD_CORNER]);
+
+			upper_rect.contains(mouse) || has_triangle_hover || inter_triangle_quad.contains(mouse)
+		} else {
+			let rect = DAffine2::from_angle_translation(angle, point) * Quad::from_box([-FULL_QUAD_CORNER, FULL_QUAD_CORNER]);
+
+			rect.contains(mouse) || has_triangle_hover
+		}
+	}
+
 	/// Update the position of the bounding box and transform handles
 	pub fn render_overlays(&mut self, overlay_context: &mut OverlayContext) {
 		let quad = self.transform * Quad::from_box(self.bounds);
@@ -388,18 +558,20 @@ impl BoundingBoxManager {
 		// Draw the bounding box rectangle
 		overlay_context.quad(quad, None);
 
-		let mut draw_handle = |point: DVec2| {
-			let quad = DAffine2::from_angle_translation((quad.top_left() - quad.top_right()).to_angle(), point)
-				* Quad::from_box([DVec2::splat(-RESIZE_HANDLE_SIZE / 2.), DVec2::splat(RESIZE_HANDLE_SIZE / 2.)]);
+		let mut draw_handle = |point: DVec2, angle: f64| {
+			let quad = DAffine2::from_angle_translation(angle, point) * Quad::from_box([DVec2::splat(-RESIZE_HANDLE_SIZE / 2.), DVec2::splat(RESIZE_HANDLE_SIZE / 2.)]);
 			overlay_context.quad(quad, Some(COLOR_OVERLAY_WHITE));
 		};
 
+		let horizontal_angle = (quad.top_left() - quad.bottom_left()).to_angle();
+		let vertical_angle = (quad.top_left() - quad.top_right()).to_angle();
+
 		// Draw the horizontal midpoint drag handles
 		if matches!(
 			category,
 			TransformCageSizeCategory::Full | TransformCageSizeCategory::Narrow | TransformCageSizeCategory::ReducedLandscape
 		) {
-			horizontal_edges.map(&mut draw_handle);
+			horizontal_edges.map(|point| draw_handle(point, horizontal_angle));
 		}
 
 		// Draw the vertical midpoint drag handles
@@ -407,21 +579,28 @@ impl BoundingBoxManager {
 			category,
 			TransformCageSizeCategory::Full | TransformCageSizeCategory::Narrow | TransformCageSizeCategory::ReducedPortrait
 		) {
-			vertical_edges.map(&mut draw_handle);
+			vertical_edges.map(|point| draw_handle(point, vertical_angle));
 		}
 
+		let angle = quad
+			.edges()
+			.map(|[x, y]| x.distance_squared(y))
+			.into_iter()
+			.reduce(|horizontal_distance, vertical_distance| if horizontal_distance > vertical_distance { horizontal_angle } else { vertical_angle })
+			.unwrap_or_default();
+
 		// Draw the corner drag handles
 		if matches!(
 			category,
 			TransformCageSizeCategory::Full | TransformCageSizeCategory::ReducedBoth | TransformCageSizeCategory::ReducedLandscape | TransformCageSizeCategory::ReducedPortrait
 		) {
-			quad.0.map(&mut draw_handle);
+			quad.0.map(|point| draw_handle(point, angle));
 		}
 
 		// Draw the flat line endpoint drag handles
 		if category == TransformCageSizeCategory::Flat {
-			draw_handle(self.transform.transform_point2(self.bounds[0]));
-			draw_handle(self.transform.transform_point2(self.bounds[1]));
+			draw_handle(self.transform.transform_point2(self.bounds[0]), angle);
+			draw_handle(self.transform.transform_point2(self.bounds[1]), angle);
 		}
 	}
 
@@ -482,7 +661,7 @@ impl BoundingBoxManager {
 	/// Returns which edge in the order:
 	///
 	/// `top, bottom, left, right`
-	pub fn check_selected_edges(&self, cursor: DVec2) -> Option<(bool, bool, bool, bool)> {
+	pub fn check_selected_edges(&self, cursor: DVec2) -> Option<EdgeBool> {
 		let cursor = self.transform.inverse().transform_point2(cursor);
 
 		let min = self.bounds[0].min(self.bounds[1]);
@@ -571,9 +750,22 @@ impl BoundingBoxManager {
 	}
 
 	/// Gets the required mouse cursor to show resizing bounds or optionally rotation
-	pub fn get_cursor(&self, input: &InputPreprocessorMessageHandler, rotate: bool) -> MouseCursorIcon {
+	pub fn get_cursor(&self, input: &InputPreprocessorMessageHandler, rotate: bool, dragging_bounds: bool, skew_edge: Option<EdgeBool>) -> MouseCursorIcon {
 		let edges = self.check_selected_edges(input.mouse.position);
 
+		let is_near_square = edges.is_some_and(|hover_edge| self.over_extended_edge_midpoint(input.mouse.position, hover_edge));
+		if dragging_bounds && is_near_square {
+			if let Some(skew_edge) = skew_edge {
+				if self.check_skew_handle(input.mouse.position, skew_edge) {
+					if skew_edge.0 || skew_edge.1 {
+						return MouseCursorIcon::EWResize;
+					} else if skew_edge.2 || skew_edge.3 {
+						return MouseCursorIcon::NSResize;
+					}
+				}
+			};
+		}
+
 		match edges {
 			Some((top, bottom, left, right)) if !self.is_bounds_flat() => match (top, bottom, left, right) {
 				(true, _, false, false) | (_, true, false, false) => MouseCursorIcon::NSResize,
@@ -599,7 +791,7 @@ fn skew_transform_singular() {
 		// The determinant is 0.
 		let transform = DAffine2::from_cols_array(&[2.; 6]);
 		// This shouldn't panic. We don't really care about the behavior in this test.
-		let _ = edge.skew_transform(DVec2::new(1.5, 1.5), transform);
+		let _ = edge.skew_transform(DVec2::new(1.5, 1.5), transform, false);
 	}
 }
 
@@ -615,7 +807,7 @@ fn skew_transform_correct() {
 		let to_viewport_transform = DAffine2::from_cols_array(&[2., 1., 0., 1., 2., 3.]);
 		// Random mouse position.
 		let mouse = DVec2::new(1.5, 1.5);
-		let final_transform = edge.skew_transform(mouse, to_viewport_transform);
+		let final_transform = edge.skew_transform(mouse, to_viewport_transform, false);
 
 		// This is the current handle that goes under the mouse.
 		let dragging_point = edge.pivot_from_bounds(edge.bounds[1], edge.bounds[0]);
diff --git a/editor/src/messages/tool/tool_messages/artboard_tool.rs b/editor/src/messages/tool/tool_messages/artboard_tool.rs
index 3bbe23ab63..943a58570f 100644
--- a/editor/src/messages/tool/tool_messages/artboard_tool.rs
+++ b/editor/src/messages/tool/tool_messages/artboard_tool.rs
@@ -393,7 +393,10 @@ impl Fsm for ArtboardToolFsmState {
 			}
 
 			(ArtboardToolFsmState::Ready { .. }, ArtboardToolMessage::PointerMove { .. }) => {
-				let mut cursor = tool_data.bounding_box_manager.as_ref().map_or(MouseCursorIcon::Default, |bounds| bounds.get_cursor(input, false));
+				let mut cursor = tool_data
+					.bounding_box_manager
+					.as_ref()
+					.map_or(MouseCursorIcon::Default, |bounds| bounds.get_cursor(input, false, false, None));
 
 				if cursor == MouseCursorIcon::Default && !hovered {
 					tool_data.snap_manager.preview_draw(&SnapData::new(document, input), input.mouse.position);
diff --git a/editor/src/messages/tool/tool_messages/select_tool.rs b/editor/src/messages/tool/tool_messages/select_tool.rs
index bcd8b8c53c..56a7155851 100644
--- a/editor/src/messages/tool/tool_messages/select_tool.rs
+++ b/editor/src/messages/tool/tool_messages/select_tool.rs
@@ -280,7 +280,7 @@ enum SelectToolFsmState {
 	Drawing { selection_shape: SelectionShapeType },
 	Dragging { axis: Axis, using_compass: bool },
 	ResizingBounds,
-	SkewingBounds,
+	SkewingBounds { skew: Key },
 	RotatingBounds,
 	DraggingPivot,
 }
@@ -309,6 +309,7 @@ struct SelectToolData {
 	cursor: MouseCursorIcon,
 	pivot: Pivot,
 	compass_rose: CompassRose,
+	skew_edge: EdgeBool,
 	nested_selection_behavior: NestedSelectionBehavior,
 	selected_layers_count: usize,
 	selected_layers_changed: bool,
@@ -577,8 +578,23 @@ impl Fsm for SelectToolFsmState {
 					.map(|bounding_box| bounding_box.check_rotate(input.mouse.position))
 					.unwrap_or_default();
 
+				let is_resizing_or_rotating = matches!(self, SelectToolFsmState::ResizingBounds | SelectToolFsmState::SkewingBounds { .. } | SelectToolFsmState::RotatingBounds);
+
+				if let Some(bounds) = tool_data.bounding_box_manager.as_mut() {
+					let edges = bounds.check_selected_edges(input.mouse.position);
+					let is_skewing = matches!(self, SelectToolFsmState::SkewingBounds { .. });
+					let is_near_square = edges.is_some_and(|hover_edge| bounds.over_extended_edge_midpoint(input.mouse.position, hover_edge));
+					if is_skewing || (dragging_bounds && is_near_square && !is_resizing_or_rotating) {
+						bounds.render_skew_gizmos(&mut overlay_context, tool_data.skew_edge);
+					}
+					if !is_skewing && dragging_bounds {
+						if let Some(edges) = edges {
+							tool_data.skew_edge = bounds.get_closest_edge(edges, input.mouse.position);
+						}
+					}
+				}
+
 				let might_resize_or_rotate = dragging_bounds || rotating_bounds;
-				let is_resizing_or_rotating = matches!(self, SelectToolFsmState::ResizingBounds { .. } | SelectToolFsmState::SkewingBounds | SelectToolFsmState::RotatingBounds);
 				let can_get_into_other_states = might_resize_or_rotate && !matches!(self, SelectToolFsmState::Dragging { .. });
 
 				let show_compass = !(can_get_into_other_states || is_resizing_or_rotating);
@@ -842,14 +858,19 @@ impl Fsm for SelectToolFsmState {
 							None
 						);
 						bounds.center_of_transformation = selected.mean_average_of_pivots();
-					}
-					tool_data.get_snap_candidates(document, input);
 
-					if input.keyboard.key(skew) {
-						SelectToolFsmState::SkewingBounds
-					} else {
-						SelectToolFsmState::ResizingBounds
+						// Check if we're hovering over a skew triangle
+						let edges = bounds.check_selected_edges(input.mouse.position);
+						if let Some(edges) = edges {
+							let closest_edge = bounds.get_closest_edge(edges, input.mouse.position);
+							if bounds.check_skew_handle(input.mouse.position, closest_edge) {
+								tool_data.get_snap_candidates(document, input);
+								return SelectToolFsmState::SkewingBounds { skew };
+							}
+						}
 					}
+					tool_data.get_snap_candidates(document, input);
+					SelectToolFsmState::ResizingBounds
 				}
 				// Dragging the selected layers around to transform them
 				else if can_grab_compass_rose || intersection.is_some_and(|intersection| selected.iter().any(|selected_layer| intersection.starts_with(*selected_layer, document.metadata()))) {
@@ -1034,10 +1055,11 @@ impl Fsm for SelectToolFsmState {
 				}
 				SelectToolFsmState::ResizingBounds
 			}
-			(SelectToolFsmState::SkewingBounds, SelectToolMessage::PointerMove(_)) => {
+			(SelectToolFsmState::SkewingBounds { skew }, SelectToolMessage::PointerMove(_)) => {
 				if let Some(ref mut bounds) = &mut tool_data.bounding_box_manager {
 					if let Some(movement) = &mut bounds.selected_edges {
-						let transformation = movement.skew_transform(input.mouse.position, bounds.original_bound_transform);
+						let free_movement = input.keyboard.key(skew);
+						let transformation = movement.skew_transform(input.mouse.position, bounds.original_bound_transform, free_movement);
 
 						tool_data.layers_dragging.retain(|layer| {
 							if *layer != LayerNodeIdentifier::ROOT_PARENT {
@@ -1063,7 +1085,7 @@ impl Fsm for SelectToolFsmState {
 						selected.apply_transformation(bounds.original_bound_transform * transformation * bounds.original_bound_transform.inverse(), None);
 					}
 				}
-				SelectToolFsmState::SkewingBounds
+				SelectToolFsmState::SkewingBounds { skew }
 			}
 			(SelectToolFsmState::RotatingBounds, SelectToolMessage::PointerMove(modifier_keys)) => {
 				if let Some(bounds) = &mut tool_data.bounding_box_manager {
@@ -1139,7 +1161,16 @@ impl Fsm for SelectToolFsmState {
 				SelectToolFsmState::Drawing { selection_shape }
 			}
 			(SelectToolFsmState::Ready { .. }, SelectToolMessage::PointerMove(_)) => {
-				let mut cursor = tool_data.bounding_box_manager.as_ref().map_or(MouseCursorIcon::Default, |bounds| bounds.get_cursor(input, true));
+				let dragging_bounds = tool_data
+					.bounding_box_manager
+					.as_mut()
+					.and_then(|bounding_box| bounding_box.check_selected_edges(input.mouse.position))
+					.is_some();
+
+				let mut cursor = tool_data
+					.bounding_box_manager
+					.as_ref()
+					.map_or(MouseCursorIcon::Default, |bounds| bounds.get_cursor(input, true, dragging_bounds, Some(tool_data.skew_edge)));
 
 				// Dragging the pivot overrules the other operations
 				if tool_data.pivot.is_over(input.mouse.position) {
@@ -1166,7 +1197,7 @@ impl Fsm for SelectToolFsmState {
 
 				SelectToolFsmState::Dragging { axis, using_compass }
 			}
-			(SelectToolFsmState::ResizingBounds | SelectToolFsmState::SkewingBounds, SelectToolMessage::PointerOutsideViewport(_)) => {
+			(SelectToolFsmState::ResizingBounds | SelectToolFsmState::SkewingBounds { .. }, SelectToolMessage::PointerOutsideViewport(_)) => {
 				// AutoPanning
 				if let Some(shift) = tool_data.auto_panning.shift_viewport(input, responses) {
 					if let Some(ref mut bounds) = &mut tool_data.bounding_box_manager {
@@ -1271,7 +1302,7 @@ impl Fsm for SelectToolFsmState {
 				let selection = tool_data.nested_selection_behavior;
 				SelectToolFsmState::Ready { selection }
 			}
-			(SelectToolFsmState::ResizingBounds | SelectToolFsmState::SkewingBounds, SelectToolMessage::DragStop { .. } | SelectToolMessage::Enter) => {
+			(SelectToolFsmState::ResizingBounds | SelectToolFsmState::SkewingBounds { .. }, SelectToolMessage::DragStop { .. } | SelectToolMessage::Enter) => {
 				let response = match input.mouse.position.distance(tool_data.drag_start) < 10. * f64::EPSILON {
 					true => DocumentMessage::AbortTransaction,
 					false => DocumentMessage::EndTransaction,
@@ -1523,7 +1554,7 @@ impl Fsm for SelectToolFsmState {
 				]);
 				responses.add(FrontendMessage::UpdateInputHints { hint_data });
 			}
-			SelectToolFsmState::DraggingPivot | SelectToolFsmState::SkewingBounds => {
+			SelectToolFsmState::DraggingPivot | SelectToolFsmState::SkewingBounds { .. } => {
 				let hint_data = HintData(vec![HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()])]);
 				responses.add(FrontendMessage::UpdateInputHints { hint_data });
 			}
diff --git a/editor/src/messages/tool/tool_messages/spline_tool.rs b/editor/src/messages/tool/tool_messages/spline_tool.rs
index 2fb1a1a6c4..9e257455f8 100644
--- a/editor/src/messages/tool/tool_messages/spline_tool.rs
+++ b/editor/src/messages/tool/tool_messages/spline_tool.rs
@@ -325,17 +325,14 @@ impl Fsm for SplineToolFsmState {
 				let append_to_selected_layer = input.keyboard.key(append_to_selected);
 
 				// Create new path in the selected layer when shift is down
-				match (selected_layer, append_to_selected_layer) {
-					(Some(layer), true) => {
-						tool_data.current_layer = Some(layer);
+				if let (Some(layer), true) = (selected_layer, append_to_selected_layer) {
+					tool_data.current_layer = Some(layer);
 
-						let transform = document.metadata().transform_to_viewport(layer);
-						let position = transform.inverse().transform_point2(input.mouse.position);
-						tool_data.next_point = position;
+					let transform = document.metadata().transform_to_viewport(layer);
+					let position = transform.inverse().transform_point2(input.mouse.position);
+					tool_data.next_point = position;
 
-						return SplineToolFsmState::Drawing;
-					}
-					_ => {}
+					return SplineToolFsmState::Drawing;
 				}
 
 				responses.add(DocumentMessage::DeselectAllLayers);


Test patch: