Repository: GraphiteEditor/Graphite (language: rust)

Issue:
Tracking Issue: Pen and Path tool improvements
Much of this is explained/demonstrated in this detailed webcast: https://youtu.be/OmveqhzXJFg

## Path tool

When dragging a handle:
- [x] <kbd>Alt</kbd> keeps the handles equidistant while pressed (instead of <kbd>Shift</kbd> in use now) (#2058)
- [x] <kbd>Alt</kbd>, as part of keeping the handles equidistant, also creates a matching handle if the other is zero-length or not a cubic Bézier (#2196)
- [x] <kbd>Shift</kbd> locks the handle angle to 15° increments (shouldn't interfere with <kbd>Shift</kbd>-clicking handles for multi-selecting points such as handles) (#2160)
- [x] <kbd>Ctrl</kbd> locks the current angle of the handle (#2160)
- [x] <kbd>Tab</kbd> swaps to dragging the opposite handle (native: teleports the cursor / web: enters pointer lock and draws a fake cursor at the new location) (#2058)
- [x] <kbd>Space</kbd> shifts the anchor (and handles) while pressed (#2065)
- [x] <kbd>C</kbd> toggles bent vs. colinear handles (instead of <kbd>Alt</kbd> in use now) (#2058)

When dragging an anchor:
- [ ] <kbd>Alt</kbd> **(new since video was recorded)**, if held during the start of the click-drag, converts the anchor's handles to colinear and drags the handle pair instead of the anchor, keeping them equidistant unless <kbd>Alt</kbd> is released to temporarily return the opposite handle back to where it started before the <kbd>Alt</kbd>-drag began (which involves returning its colinear state back to what it was before). Should work with <kbd>Tab</kbd> to swap which is the dragged handle, so its opposite can be the one that's returned if <kbd>Alt</kbd> is temporarily released. Just <kbd>Alt</kbd> clicking but not dragging the anchor should remove both handles by setting them to zero length.
- [ ] <kbd>Ctrl</kbd> **(new since video was recorded)**, if pressed before the drag begins, should select and drag a zero-length handle out of its anchor locked to the angle of the opposite non-zero-length handle, which it should be made colinear to. This applies only to anchors with two handles where exactly one is zero-length. Otherwise, the key is ignored and the anchor is dragged per usual. One more scenario is where a non-endpoint anchor has no handles (they're both zero-length), if <kbd>Ctrl</kbd> and <kbd>Alt</kbd> (see previous bullet point) are both pressed before dragging, it locks the angle to that of the [line perpendicular to the average of the two tangents](https://files.keavon.com/-/NovelGruesomePangolin/capture.png) from that anchor to its adjacent curves.
- [ ] <kbd>Shift</kbd>, locks the drag to the nearer of the X or Y axes from its drag starting point. (If pressed before the drag begins, it shouldn't break <kbd>Shift</kbd>-clicking points to add to the selection.)
- [ ] <kbd>Ctrl</kbd><kbd>Shift</kbd> **(updated since video was recorded which indicated this would use <kbd>Alt</kbd>)** slides the anchor along its adjacent segment(s) while adjusting its handle(s) to best approximate the initial curvature. This will require a computational geometry algorithm researched and added to our Bezier-rs library. (If pressed before the drag begins, it shouldn't break <kbd>Shift</kbd>-clicking points to add to the selection.)

When double-clicking an anchor **(new since video was recorded)**:
- [ ] Currently this swaps between smooth and sharp, but doesn't work if multiple handles are multi-selected to convert them all to smooth or sharp. If they're of mixed type, the specific on that's double clicked on should be the decider for the rest of them as to which they should all be swapped to.

When clicking a segment **(new since video was recorded)**:
- [ ] Clicking without any modifier key should split the segment where clicked. Currently this has the extra complication of entering an insertion mode where the point of the segment split can be slid within the segment and left click confirms it while right click cancels. We want to remove that extra step and just insert directly. An overlay should be drawn in cases where the cursor is hovered over the segment such that clicking would insert a split, it should be a short line segment (20px in diameter) running perpendicular to the curve.
- [ ] <kbd>Ctrl</kbd> clicking should delete the segment that's clicked. Currently a user would have to split the segment then delete the newly created point.
- [ ] Clicking and dragging without any modifier key should [mold the segment](https://pomax.github.io/bezierinfo/#molding) as per #615.

When a single handle is selected:
- [x] <kbd>R</kbd> rotates about its anchor (#2180)
- [x] <kbd>S</kbd> scales about its anchor (#2180)

## Pen tool

When no drawing has begun yet (pointer is up):
- [x] **(New since video was recorded)** clicking a path's endpoint should begin drawing from it with an initial handle that's colinear and equidistant from the opposite handle, instead of the current behavior where clicking the endpoint begins drawing the segment with a zero-length handle. If the user wants the current behavior, they can simply click the endpoint anchor a second time to make the current segment's handle zero-length. This solves the pain point that switching away from the Pen tool, then back to it, causes a discontinuity at the previous endpoint where the handles can't easily be opposite each other like when drawing consecutive segments normally. (#2295)

When stretching out a segment to be placed (pointer is up):
- [x] <kbd>Backspace</kbd>/<kbd>Delete</kbd> removes the current segment's handle (equivalent to clicking the current anchor) (#2180)
- [x] <kbd>G</kbd> grabs (translates) the current segment's connected outgoing handle (#2211)
- [x] <kbd>R</kbd> rotates the current segment's connected outgoing handle about its anchor (#2211)
- [x] <kbd>S</kbd> scales the current segment's connected outgoing handle about its anchor (#2211)

When dragging the handles of the segment being placed (pointer is down):
- [x] <kbd>C</kbd> toggles bent vs. colinear handles (instead of the momentary <kbd>Alt</kbd> in use now) (#2242)
- [x] <kbd>Alt</kbd> stops keeping the handles equidistant while pressed, as long as they're currently colinear (otherwise it does nothing). Exception: the reverse is the case (it keeps the handles equidistant while pressed) when the click-drag began with a single handle (either by click-dragging the current segment's connected anchor, click-dragging an endpoint of an existing not-yet-being-edited layer, or click-dragging the start point to close the shape) but <kbd>Ctrl</kbd> was pressed to keep (#2242)
- [x] <kbd>Ctrl</kbd> locks the current angle of the handle (already implemented), but should also work to be colinear with the opposite handle if being dragged out of an existing anchor point, or if that anchor point has no handle then it should use the angle of the actual curve segment entering that handle, i.e. its tangent angle at its endpoint (#2284)
- [ ] <kbd>Space</kbd> shifts the anchor (and handles) while pressed
- [ ] <kbd>Tab</kbd> swaps to dragging the opposite handle (native: teleports the cursor / web: enters pointer lock and draws a fake cursor at the new location)
- [ ] If the current segment is closing the shape by connecting to its start point, the handle that's being dragged should be the closing handle, not a fake handle on the opposite side, and all the above shortcuts should work on that anchor/handle group spanning the start/end point (<kbd>Tab</kbd> will modify the shape of the first segment while the current/final segment is still in-progress)
- [ ] All the above shortcuts should work with the "Bend Prev. Point" case where the current segment's connected anchor can be clicked to turn it sharp or click-dragged to bend its outgoing handle (turning its handles to not colinear), meaning <kbd>C</kbd> should be able to turn it back to colinear

---

- Not yet figured out: improving the UX for extending an endpoint with a new segment, by having control to extend the handle on either end of the new segment instead of just the new endpoint's side, and by locking the angle of the new endpoint side's handle to avoid a discontinuity. [Video](https://files.keavon.com/-/PoliticalGiantQueensnake/capture_22_.mp4).


Discussion / hints:
Aight, I start with it. Wish me luck
Here are some more potential additions (coming from features I found useful in an abandoned vector graphics editor I had been working on years ago; originally listed [here](https://news.ycombinator.com/item?id=41854972)):

- shift+G/R/S (or some other modifier) for setting handle mode (bent, colinear, and colinear+equidistant respectively) is a potential intuitive keybind option kinda following what regular g/r/s do;
- that handle mode can be displayed visually as different icons on the point (square for bent, rectangle aligned to angle for colinear, and perhaps circle for equidistant, though that may get confusing with handles being circles; ellipse?)
- allow both rotate and scale at the same time (maybe never useful but I still had this ¯\\\_(ツ)\_/¯)
- middle-mouse-dragging while holding an element should ignore the delta mouse movement during the movement (currently doing this in Graphite results in jumpiness)
- another option for visual indicators r/g/s:
  - g: a line from the starting mouse position in the canvas to where it'd be dragged to
  - s: line from the anchor point through the current and original mouse position with different colors (i.e. the ratio of the color lengths is the ratio of scaling)
  - r: lines from the anchor to original & current mouse position, with an arc in between (very busy-looking, don't quite like it)

Some general actions:
- cut a path into two, snapping to cutting on an existing point if near enough (potentially, if dragging, could cut into three segments)
- join paths if two end-points are selected (incl. closing a path)
- select all points linked to currently already selected ones

[screen recording](https://github.com/user-attachments/assets/f64318f6-7f70-4b6e-88ad-33edaccffed8) showing some of these in my editor


Thank you for posting those here @dzaima! The feedback and ideas are very useful.

Fix patch:
diff --git a/editor/src/messages/input_mapper/input_mappings.rs b/editor/src/messages/input_mapper/input_mappings.rs
index a3c50173e5..654310bebe 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 {
 		//
 		// SelectToolMessage
 		entry!(PointerMove; refresh_keys=[Control, Alt, Shift], action_dispatch=SelectToolMessage::PointerMove(SelectToolPointerKeys { axis_align: Shift, snap_angle: Control, center: Alt, duplicate: Alt })),
-		entry!(KeyDown(MouseLeft); action_dispatch=SelectToolMessage::DragStart { add_to_selection: Shift, select_deepest: Accel }),
+		entry!(KeyDown(MouseLeft); action_dispatch=SelectToolMessage::DragStart { extend_selection: Shift, select_deepest: Accel }),
 		entry!(KeyUp(MouseLeft); action_dispatch=SelectToolMessage::DragStop { remove_from_selection: Shift }),
 		entry!(KeyDown(Enter); action_dispatch=SelectToolMessage::Enter),
 		entry!(DoubleClick(MouseButton::Left); action_dispatch=SelectToolMessage::EditLayer),
@@ -204,19 +204,20 @@ pub fn input_mappings() -> Mapping {
 		entry!(KeyDown(Backspace); modifiers=[Accel], action_dispatch=PathToolMessage::DeleteAndBreakPath),
 		entry!(KeyDown(Delete); modifiers=[Accel, Shift], action_dispatch=PathToolMessage::BreakPath),
 		entry!(KeyDown(Backspace); modifiers=[Accel, Shift], action_dispatch=PathToolMessage::BreakPath),
-		entry!(KeyDown(MouseLeft); action_dispatch=PathToolMessage::MouseDown { ctrl: Control, shift: Shift }),
+		entry!(KeyDown(Tab); action_dispatch=PathToolMessage::SwapSelectedHandles),
+		entry!(KeyDown(MouseLeft); action_dispatch=PathToolMessage::MouseDown { direct_insert_without_sliding: Control, extend_selection: Shift }),
 		entry!(KeyDown(MouseRight); action_dispatch=PathToolMessage::RightClick),
 		entry!(KeyDown(Escape); action_dispatch=PathToolMessage::Escape),
 		entry!(KeyDown(KeyG); action_dispatch=PathToolMessage::GRS { key: KeyG }),
 		entry!(KeyDown(KeyR); action_dispatch=PathToolMessage::GRS { key: KeyR }),
 		entry!(KeyDown(KeyS); action_dispatch=PathToolMessage::GRS { key: KeyS }),
-		entry!(PointerMove; refresh_keys=[Alt, Shift, Space], action_dispatch=PathToolMessage::PointerMove { alt: Alt, shift: Shift, move_anchor_and_handles: Space}),
+		entry!(PointerMove; refresh_keys=[KeyC, Shift, Alt, Space], action_dispatch=PathToolMessage::PointerMove { toggle_colinear: KeyC, equidistant: Alt, move_anchor_with_handles: Space}),
 		entry!(KeyDown(Delete); action_dispatch=PathToolMessage::Delete),
 		entry!(KeyDown(KeyA); modifiers=[Accel], action_dispatch=PathToolMessage::SelectAllAnchors),
 		entry!(KeyDown(KeyA); modifiers=[Accel, Shift], action_dispatch=PathToolMessage::DeselectAllPoints),
 		entry!(KeyDown(Backspace); action_dispatch=PathToolMessage::Delete),
-		entry!(KeyUp(MouseLeft); action_dispatch=PathToolMessage::DragStop { equidistant: Shift }),
-		entry!(KeyDown(Enter); action_dispatch=PathToolMessage::Enter { add_to_selection: Shift }),
+		entry!(KeyUp(MouseLeft); action_dispatch=PathToolMessage::DragStop { extend_selection: Shift }),
+		entry!(KeyDown(Enter); action_dispatch=PathToolMessage::Enter { extend_selection: Shift }),
 		entry!(DoubleClick(MouseButton::Left); action_dispatch=PathToolMessage::FlipSmoothSharp),
 		entry!(KeyDown(ArrowRight); action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: NUDGE_AMOUNT, delta_y: 0. }),
 		entry!(KeyDown(ArrowRight); modifiers=[Shift], action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: BIG_NUDGE_AMOUNT, delta_y: 0. }),
diff --git a/editor/src/messages/tool/common_functionality/shape_editor.rs b/editor/src/messages/tool/common_functionality/shape_editor.rs
index 8dd0087fe5..774dff580d 100644
--- a/editor/src/messages/tool/common_functionality/shape_editor.rs
+++ b/editor/src/messages/tool/common_functionality/shape_editor.rs
@@ -4,6 +4,7 @@ use crate::messages::portfolio::document::utility_types::document_metadata::{Doc
 use crate::messages::portfolio::document::utility_types::misc::{GeometrySnapSource, SnapSource};
 use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface;
 use crate::messages::prelude::*;
+use crate::messages::tool::tool_messages::path_tool::PointSelectState;
 
 use bezier_rs::{Bezier, BezierHandles, TValue};
 use graphene_core::transform::Transform;
@@ -12,8 +13,9 @@ use graphene_core::vector::{ManipulatorPointId, PointId, VectorData, VectorModif
 use glam::DVec2;
 use graphene_std::vector::{HandleId, SegmentId};
 
-#[derive(Debug, PartialEq, Copy, Clone)]
+#[derive(Debug, PartialEq, Eq, Copy, Clone, Default)]
 pub enum ManipulatorAngle {
+	#[default]
 	Colinear,
 	Free,
 	Mixed,
@@ -161,9 +163,9 @@ impl ClosestSegment {
 		midpoint
 	}
 
-	pub fn adjusted_insert_and_select(&self, shape_editor: &mut ShapeState, responses: &mut VecDeque<Message>, add_to_selection: bool) {
+	pub fn adjusted_insert_and_select(&self, shape_editor: &mut ShapeState, responses: &mut VecDeque<Message>, extend_selection: bool) {
 		let id = self.adjusted_insert(responses);
-		shape_editor.select_anchor_point_by_id(self.layer, id, add_to_selection)
+		shape_editor.select_anchor_point_by_id(self.layer, id, extend_selection)
 	}
 }
 
@@ -221,7 +223,7 @@ impl ShapeState {
 
 	/// Select/deselect the first point within the selection threshold.
 	/// Returns a tuple of the points if found and the offset, or `None` otherwise.
-	pub fn change_point_selection(&mut self, network_interface: &NodeNetworkInterface, mouse_position: DVec2, select_threshold: f64, add_to_selection: bool) -> Option<Option<SelectedPointsInfo>> {
+	pub fn change_point_selection(&mut self, network_interface: &NodeNetworkInterface, mouse_position: DVec2, select_threshold: f64, extend_selection: bool) -> Option<Option<SelectedPointsInfo>> {
 		if self.selected_shape_state.is_empty() {
 			return None;
 		}
@@ -234,14 +236,14 @@ impl ShapeState {
 			let already_selected = selected_shape_state.is_selected(manipulator_point_id);
 
 			// Should we select or deselect the point?
-			let new_selected = if already_selected { !add_to_selection } else { true };
+			let new_selected = if already_selected { !extend_selection } else { true };
 
 			// Offset to snap the selected point to the cursor
 			let offset = mouse_position - network_interface.document_metadata().transform_to_viewport(layer).transform_point2(point_position);
 
 			// This is selecting the manipulator only for now, next to generalize to points
 			if new_selected {
-				let retain_existing_selection = add_to_selection || already_selected;
+				let retain_existing_selection = extend_selection || already_selected;
 				if !retain_existing_selection {
 					self.deselect_all_points();
 				}
@@ -267,8 +269,8 @@ impl ShapeState {
 		None
 	}
 
-	pub fn select_anchor_point_by_id(&mut self, layer: LayerNodeIdentifier, id: PointId, add_to_selection: bool) {
-		if !add_to_selection {
+	pub fn select_anchor_point_by_id(&mut self, layer: LayerNodeIdentifier, id: PointId, extend_selection: bool) {
+		if !extend_selection {
 			self.deselect_all_points();
 		}
 		let point = ManipulatorPointId::Anchor(id);
@@ -1060,6 +1062,71 @@ impl ShapeState {
 			_ => self.sorted_selected_layers(network_interface.document_metadata()).find_map(closest_seg),
 		}
 	}
+	pub fn get_dragging_state(&self, network_interface: &NodeNetworkInterface) -> PointSelectState {
+		for &layer in self.selected_shape_state.keys() {
+			let Some(vector_data) = network_interface.compute_modified_vector(layer) else { continue };
+
+			for point in self.selected_points() {
+				if point.as_anchor().is_some() {
+					return PointSelectState::Anchor;
+				}
+				if point.get_handle_pair(&vector_data).is_some() {
+					return PointSelectState::HandleWithPair;
+				}
+			}
+		}
+		PointSelectState::HandleNoPair
+	}
+
+	/// Returns true if at least one handle with pair is selected
+	pub fn handle_with_pair_selected(&mut self, network_interface: &NodeNetworkInterface) -> bool {
+		for &layer in self.selected_shape_state.keys() {
+			let Some(vector_data) = network_interface.compute_modified_vector(layer) else { continue };
+
+			for point in self.selected_points() {
+				if point.as_anchor().is_some() {
+					return false;
+				}
+				if point.get_handle_pair(&vector_data).is_some() {
+					return true;
+				}
+			}
+		}
+
+		false
+	}
+
+	/// Alternate selected handles to mirrors
+	pub fn alternate_selected_handles(&mut self, network_interface: &NodeNetworkInterface) {
+		let mut handles_to_update = Vec::new();
+
+		for &layer in self.selected_shape_state.keys() {
+			let Some(vector_data) = network_interface.compute_modified_vector(layer) else { continue };
+
+			for point in self.selected_points() {
+				if point.as_anchor().is_some() {
+					continue;
+				}
+				if let Some(handles) = point.get_handle_pair(&vector_data) {
+					// handle[0] is selected, handle[1] is opposite / mirror handle
+					handles_to_update.push((layer, handles[0].to_manipulator_point(), handles[1].to_manipulator_point()));
+				}
+			}
+		}
+
+		for (layer, handle_to_deselect, handle_to_select) in handles_to_update {
+			if let Some(state) = self.selected_shape_state.get_mut(&layer) {
+				let points = &state.selected_points;
+				let both_selected = points.contains(&handle_to_deselect) && points.contains(&handle_to_select);
+				if both_selected {
+					continue;
+				}
+
+				state.deselect_point(handle_to_deselect);
+				state.select_point(handle_to_select);
+			}
+		}
+	}
 
 	/// Selects handles and anchor connected to current handle
 	pub fn select_handles_and_anchor_connected_to_current_handle(&mut self, network_interface: &NodeNetworkInterface) {
diff --git a/editor/src/messages/tool/tool_messages/path_tool.rs b/editor/src/messages/tool/tool_messages/path_tool.rs
index e759447050..074af7130b 100644
--- a/editor/src/messages/tool/tool_messages/path_tool.rs
+++ b/editor/src/messages/tool/tool_messages/path_tool.rs
@@ -34,10 +34,10 @@ pub enum PathToolMessage {
 	Delete,
 	DeleteAndBreakPath,
 	DragStop {
-		equidistant: Key,
+		extend_selection: Key,
 	},
 	Enter {
-		add_to_selection: Key,
+		extend_selection: Key,
 	},
 	Escape,
 	FlipSmoothSharp,
@@ -48,22 +48,22 @@ pub enum PathToolMessage {
 	ManipulatorMakeHandlesFree,
 	ManipulatorMakeHandlesColinear,
 	MouseDown {
-		ctrl: Key,
-		shift: Key,
+		direct_insert_without_sliding: Key,
+		extend_selection: Key,
 	},
 	NudgeSelectedPoints {
 		delta_x: f64,
 		delta_y: f64,
 	},
 	PointerMove {
-		alt: Key,
-		shift: Key,
-		move_anchor_and_handles: Key,
+		equidistant: Key,
+		toggle_colinear: Key,
+		move_anchor_with_handles: Key,
 	},
 	PointerOutsideViewport {
-		alt: Key,
-		shift: Key,
-		move_anchor_and_handles: Key,
+		equidistant: Key,
+		toggle_colinear: Key,
+		move_anchor_with_handles: Key,
 	},
 	RightClick,
 	SelectAllAnchors,
@@ -74,6 +74,7 @@ pub enum PathToolMessage {
 	SelectedPointYChanged {
 		new_y: f64,
 	},
+	SwapSelectedHandles,
 }
 
 impl ToolMetadata for PathTool {
@@ -170,7 +171,19 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionHandlerData<'a>> for PathToo
 	fn process_message(&mut self, message: ToolMessage, responses: &mut VecDeque<Message>, tool_data: &mut ToolActionHandlerData<'a>) {
 		let updating_point = message == ToolMessage::Path(PathToolMessage::SelectedPointUpdated);
 
-		self.fsm_state.process_event(message, &mut self.tool_data, tool_data, &(), responses, true);
+		match message {
+			ToolMessage::Path(PathToolMessage::SwapSelectedHandles) => {
+				if tool_data.shape_editor.handle_with_pair_selected(&tool_data.document.network_interface) {
+					tool_data.shape_editor.alternate_selected_handles(&tool_data.document.network_interface);
+					responses.add(PathToolMessage::SelectedPointUpdated);
+					responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::None });
+					responses.add(OverlaysMessage::Draw);
+				}
+			}
+			_ => {
+				self.fsm_state.process_event(message, &mut self.tool_data, tool_data, &(), responses, true);
+			}
+		}
 
 		if updating_point {
 			self.send_layout(responses, LayoutTarget::ToolOptions);
@@ -191,7 +204,7 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionHandlerData<'a>> for PathToo
 				BreakPath,
 				DeleteAndBreakPath,
 			),
-			PathToolFsmState::Dragging => actions!(PathToolMessageDiscriminant;
+			PathToolFsmState::Dragging(_) => actions!(PathToolMessageDiscriminant;
 				Escape,
 				RightClick,
 				FlipSmoothSharp,
@@ -200,6 +213,7 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionHandlerData<'a>> for PathToo
 				Delete,
 				BreakPath,
 				DeleteAndBreakPath,
+				SwapSelectedHandles,
 			),
 			PathToolFsmState::DrawingBox => actions!(PathToolMessageDiscriminant;
 				FlipSmoothSharp,
@@ -235,19 +249,32 @@ impl ToolTransition for PathTool {
 		}
 	}
 }
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub struct DraggingState {
+	point_select_state: PointSelectState,
+	colinear: ManipulatorAngle,
+}
+
+#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
+pub enum PointSelectState {
+	HandleWithPair,
+	#[default]
+	HandleNoPair,
+	Anchor,
+}
 
 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
 enum PathToolFsmState {
 	#[default]
 	Ready,
-	Dragging,
+	Dragging(DraggingState),
 	DrawingBox,
 	InsertPoint,
 }
 
 enum InsertEndKind {
 	Abort,
-	Add { shift: bool },
+	Add { extend_selection: bool },
 }
 
 #[derive(Default)]
@@ -255,7 +282,7 @@ struct PathToolData {
 	snap_manager: SnapManager,
 	drag_start_pos: DVec2,
 	previous_mouse_position: DVec2,
-	alt_debounce: bool,
+	toggle_colinear_debounce: bool,
 	opposing_handle_lengths: Option<OpposingHandleLengths>,
 	/// Describes information about the selected point(s), if any, across one or multiple shapes and manipulator point types (anchor or handle).
 	/// The available information varies depending on whether `None`, `One`, or `Multiple` points are currently selected.
@@ -266,12 +293,13 @@ struct PathToolData {
 	auto_panning: AutoPanning,
 	saved_points_before_anchor_select_toggle: Vec<ManipulatorPointId>,
 	select_anchor_toggled: bool,
+	dragging_state: DraggingState,
 }
 
 impl PathToolData {
 	fn save_points_before_anchor_toggle(&mut self, points: Vec<ManipulatorPointId>) -> PathToolFsmState {
 		self.saved_points_before_anchor_select_toggle = points;
-		PathToolFsmState::Dragging
+		PathToolFsmState::Dragging(self.dragging_state)
 	}
 
 	fn remove_saved_points(&mut self) {
@@ -308,8 +336,8 @@ impl PathToolData {
 				warn!("Segment was `None` before `end_insertion`")
 			}
 			Some(closed_segment) => {
-				if let InsertEndKind::Add { shift } = kind {
-					closed_segment.adjusted_insert_and_select(shape_editor, responses, shift);
+				if let InsertEndKind::Add { extend_selection } = kind {
+					closed_segment.adjusted_insert_and_select(shape_editor, responses, extend_selection);
 					commit_transaction = true;
 				}
 			}
@@ -331,7 +359,7 @@ impl PathToolData {
 		document: &DocumentMessageHandler,
 		input: &InputPreprocessorMessageHandler,
 		responses: &mut VecDeque<Message>,
-		add_to_selection: bool,
+		extend_selection: bool,
 		direct_insert_without_sliding: bool,
 	) -> PathToolFsmState {
 		self.double_click_handled = false;
@@ -340,7 +368,7 @@ impl PathToolData {
 		self.drag_start_pos = input.mouse.position;
 
 		// Select the first point within the threshold (in pixels)
-		if let Some(selected_points) = shape_editor.change_point_selection(&document.network_interface, input.mouse.position, SELECTION_THRESHOLD, add_to_selection) {
+		if let Some(selected_points) = shape_editor.change_point_selection(&document.network_interface, input.mouse.position, SELECTION_THRESHOLD, extend_selection) {
 			responses.add(DocumentMessage::StartTransaction);
 
 			if let Some(selected_points) = selected_points {
@@ -348,21 +376,21 @@ impl PathToolData {
 				self.start_dragging_point(selected_points, input, document, shape_editor);
 				responses.add(OverlaysMessage::Draw);
 			}
-			PathToolFsmState::Dragging
+			PathToolFsmState::Dragging(self.dragging_state)
 		}
 		// We didn't find a point nearby, so now we'll try to add a point into the closest path segment
 		else if let Some(closed_segment) = shape_editor.upper_closest_segment(&document.network_interface, input.mouse.position, SELECTION_TOLERANCE) {
 			responses.add(DocumentMessage::StartTransaction);
 			if direct_insert_without_sliding {
 				self.start_insertion(responses, closed_segment);
-				self.end_insertion(shape_editor, responses, InsertEndKind::Add { shift: add_to_selection })
+				self.end_insertion(shape_editor, responses, InsertEndKind::Add { extend_selection })
 			} else {
 				self.start_insertion(responses, closed_segment)
 			}
 		}
 		// We didn't find a segment path, so consider selecting the nearest shape instead
 		else if let Some(layer) = document.click(input) {
-			if add_to_selection {
+			if extend_selection {
 				responses.add(NodeGraphMessage::SelectedNodesAdd { nodes: vec![layer.to_node()] });
 			} else {
 				responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] });
@@ -372,7 +400,8 @@ impl PathToolData {
 			shape_editor.select_connected_anchors(document, layer, input.mouse.position);
 
 			responses.add(DocumentMessage::StartTransaction);
-			PathToolFsmState::Dragging
+
+			PathToolFsmState::Dragging(self.dragging_state)
 		}
 		// Start drawing a box
 		else {
@@ -413,9 +442,9 @@ impl PathToolData {
 		self.previous_mouse_position = viewport_to_document.transform_point2(input.mouse.position - selected_points.offset);
 	}
 
-	fn update_colinear(&mut self, shift: bool, alt: bool, shape_editor: &mut ShapeState, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>) -> bool {
-		// Check if the alt key has just been pressed
-		if alt && !self.alt_debounce {
+	fn update_colinear(&mut self, equidistant: bool, toggle_colinear: bool, shape_editor: &mut ShapeState, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>) -> bool {
+		// Check if the toggle_colinear key has just been pressed
+		if toggle_colinear && !self.toggle_colinear_debounce {
 			self.opposing_handle_lengths = None;
 			let colinear = self.selection_status.angle().map_or(false, |angle| match angle {
 				ManipulatorAngle::Colinear => true,
@@ -427,12 +456,12 @@ impl PathToolData {
 			} else {
 				shape_editor.convert_selected_manipulators_to_colinear_handles(responses, document);
 			}
-			self.alt_debounce = true;
+			self.toggle_colinear_debounce = true;
 			return true;
 		}
-		self.alt_debounce = alt;
+		self.toggle_colinear_debounce = toggle_colinear;
 
-		if shift && self.opposing_handle_lengths.is_none() {
+		if equidistant && self.opposing_handle_lengths.is_none() {
 			self.opposing_handle_lengths = Some(shape_editor.opposing_handle_lengths(document));
 		}
 		false
@@ -481,7 +510,7 @@ impl Fsm for PathToolFsmState {
 
 						overlay_context.quad(Quad::from_box([tool_data.drag_start_pos, tool_data.previous_mouse_position]), Some(&("#".to_string() + &fill_color)));
 					}
-					Self::Dragging => {
+					Self::Dragging(_) => {
 						tool_data.snap_manager.draw_overlays(SnapData::new(document, input), &mut overlay_context);
 					}
 					Self::InsertPoint => {
@@ -502,11 +531,10 @@ impl Fsm for PathToolFsmState {
 			}
 
 			// `Self::InsertPoint` case:
-			(Self::InsertPoint, PathToolMessage::MouseDown { .. } | PathToolMessage::Enter { .. }) => {
+			(Self::InsertPoint, PathToolMessage::MouseDown { extend_selection, .. } | PathToolMessage::Enter { extend_selection }) => {
 				tool_data.double_click_handled = true;
-				// TODO: Don't use `Key::Shift` directly, instead take it as a variable from the input mappings list like in all other places
-				let shift = input.keyboard.get(Key::Shift as usize);
-				tool_data.end_insertion(shape_editor, responses, InsertEndKind::Add { shift })
+				let extend_selection = input.keyboard.get(extend_selection as usize);
+				tool_data.end_insertion(shape_editor, responses, InsertEndKind::Add { extend_selection })
 			}
 			(Self::InsertPoint, PathToolMessage::PointerMove { .. }) => {
 				responses.add(OverlaysMessage::Draw);
@@ -529,26 +557,56 @@ impl Fsm for PathToolFsmState {
 				tool_data.end_insertion(shape_editor, responses, InsertEndKind::Abort)
 			}
 			// Mouse down
-			(_, PathToolMessage::MouseDown { ctrl, shift }) => {
-				let add_to_selection = input.keyboard.get(shift as usize);
-				let direct_insert_without_sliding = input.keyboard.get(ctrl as usize);
-				tool_data.mouse_down(shape_editor, document, input, responses, add_to_selection, direct_insert_without_sliding)
-			}
-			(PathToolFsmState::DrawingBox, PathToolMessage::PointerMove { alt, shift, move_anchor_and_handles }) => {
+			(
+				_,
+				PathToolMessage::MouseDown {
+					direct_insert_without_sliding,
+					extend_selection,
+				},
+			) => {
+				let extend_selection = input.keyboard.get(extend_selection as usize);
+				let direct_insert_without_sliding = input.keyboard.get(direct_insert_without_sliding as usize);
+				tool_data.mouse_down(shape_editor, document, input, responses, extend_selection, direct_insert_without_sliding)
+			}
+			(
+				PathToolFsmState::DrawingBox,
+				PathToolMessage::PointerMove {
+					equidistant,
+					toggle_colinear,
+					move_anchor_with_handles,
+				},
+			) => {
 				tool_data.previous_mouse_position = input.mouse.position;
 				responses.add(OverlaysMessage::Draw);
 
 				// Auto-panning
 				let messages = [
-					PathToolMessage::PointerOutsideViewport { alt, shift, move_anchor_and_handles }.into(),
-					PathToolMessage::PointerMove { alt, shift, move_anchor_and_handles }.into(),
+					PathToolMessage::PointerOutsideViewport {
+						equidistant,
+						toggle_colinear,
+						move_anchor_with_handles,
+					}
+					.into(),
+					PathToolMessage::PointerMove {
+						equidistant,
+						toggle_colinear,
+						move_anchor_with_handles,
+					}
+					.into(),
 				];
 				tool_data.auto_panning.setup_by_mouse_position(input, &messages, responses);
 
 				PathToolFsmState::DrawingBox
 			}
-			(PathToolFsmState::Dragging, PathToolMessage::PointerMove { alt, shift, move_anchor_and_handles }) => {
-				let anchor_and_handle_toggled = input.keyboard.get(move_anchor_and_handles as usize);
+			(
+				PathToolFsmState::Dragging(_),
+				PathToolMessage::PointerMove {
+					equidistant,
+					toggle_colinear,
+					move_anchor_with_handles,
+				},
+			) => {
+				let anchor_and_handle_toggled = input.keyboard.get(move_anchor_with_handles as usize);
 				let initial_press = anchor_and_handle_toggled && !tool_data.select_anchor_toggled;
 				let released_from_toggle = tool_data.select_anchor_toggled && !anchor_and_handle_toggled;
 
@@ -565,63 +623,89 @@ impl Fsm for PathToolFsmState {
 					tool_data.remove_saved_points();
 				}
 
-				let alt_state = input.keyboard.get(alt as usize);
-				let shift_state = input.keyboard.get(shift as usize);
-				if !tool_data.update_colinear(shift_state, alt_state, shape_editor, document, responses) {
-					tool_data.drag(shift_state, shape_editor, document, input, responses);
+				let toggle_colinear_state = input.keyboard.get(toggle_colinear as usize);
+				let equidistant_state = input.keyboard.get(equidistant as usize);
+				if !tool_data.update_colinear(equidistant_state, toggle_colinear_state, shape_editor, document, responses) {
+					tool_data.drag(equidistant_state, shape_editor, document, input, responses);
 				}
 
 				// Auto-panning
 				let messages = [
-					PathToolMessage::PointerOutsideViewport { alt, shift, move_anchor_and_handles }.into(),
-					PathToolMessage::PointerMove { alt, shift, move_anchor_and_handles }.into(),
+					PathToolMessage::PointerOutsideViewport {
+						toggle_colinear,
+						equidistant,
+						move_anchor_with_handles,
+					}
+					.into(),
+					PathToolMessage::PointerMove {
+						toggle_colinear,
+						equidistant,
+						move_anchor_with_handles,
+					}
+					.into(),
 				];
 				tool_data.auto_panning.setup_by_mouse_position(input, &messages, responses);
 
-				PathToolFsmState::Dragging
+				PathToolFsmState::Dragging(tool_data.dragging_state)
 			}
 			(PathToolFsmState::DrawingBox, PathToolMessage::PointerOutsideViewport { .. }) => {
 				// Auto-panning
-				if let Some(shift) = tool_data.auto_panning.shift_viewport(input, responses) {
-					tool_data.drag_start_pos += shift;
+				if let Some(offset) = tool_data.auto_panning.shift_viewport(input, responses) {
+					tool_data.drag_start_pos += offset;
 				}
 
 				PathToolFsmState::DrawingBox
 			}
-			(PathToolFsmState::Dragging, PathToolMessage::PointerOutsideViewport { shift, .. }) => {
+			(PathToolFsmState::Dragging(dragging_state), PathToolMessage::PointerOutsideViewport { equidistant, .. }) => {
 				// Auto-panning
 				if tool_data.auto_panning.shift_viewport(input, responses).is_some() {
-					let shift_state = input.keyboard.get(shift as usize);
-					tool_data.drag(shift_state, shape_editor, document, input, responses);
+					let equidistant = input.keyboard.get(equidistant as usize);
+					tool_data.drag(equidistant, shape_editor, document, input, responses);
 				}
 
-				PathToolFsmState::Dragging
+				PathToolFsmState::Dragging(dragging_state)
 			}
-			(state, PathToolMessage::PointerOutsideViewport { alt, shift, move_anchor_and_handles }) => {
+			(
+				state,
+				PathToolMessage::PointerOutsideViewport {
+					equidistant,
+					toggle_colinear,
+					move_anchor_with_handles,
+				},
+			) => {
 				// Auto-panning
 				let messages = [
-					PathToolMessage::PointerOutsideViewport { alt, shift, move_anchor_and_handles }.into(),
-					PathToolMessage::PointerMove { alt, shift, move_anchor_and_handles }.into(),
+					PathToolMessage::PointerOutsideViewport {
+						equidistant,
+						toggle_colinear,
+						move_anchor_with_handles,
+					}
+					.into(),
+					PathToolMessage::PointerMove {
+						equidistant,
+						toggle_colinear,
+						move_anchor_with_handles,
+					}
+					.into(),
 				];
 				tool_data.auto_panning.stop(&messages, responses);
 
 				state
 			}
-			(PathToolFsmState::DrawingBox, PathToolMessage::Enter { add_to_selection }) => {
-				let shift_pressed = input.keyboard.get(add_to_selection as usize);
+			(PathToolFsmState::DrawingBox, PathToolMessage::Enter { extend_selection }) => {
+				let extend_selection = input.keyboard.get(extend_selection as usize);
 
 				if tool_data.drag_start_pos == tool_data.previous_mouse_position {
 					responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![] });
 				} else {
-					shape_editor.select_all_in_quad(&document.network_interface, [tool_data.drag_start_pos, tool_data.previous_mouse_position], !shift_pressed);
+					shape_editor.select_all_in_quad(&document.network_interface, [tool_data.drag_start_pos, tool_data.previous_mouse_position], !extend_selection);
 				}
 				responses.add(OverlaysMessage::Draw);
 
 				PathToolFsmState::Ready
 			}
-			(PathToolFsmState::Dragging, PathToolMessage::Escape | PathToolMessage::RightClick) => {
+			(PathToolFsmState::Dragging { .. }, PathToolMessage::Escape | PathToolMessage::RightClick) => {
 				responses.add(DocumentMessage::AbortTransaction);
-				shape_editor.deselect_all_points();
 				tool_data.snap_manager.cleanup(responses);
 				PathToolFsmState::Ready
 			}
@@ -630,20 +714,20 @@ impl Fsm for PathToolFsmState {
 				PathToolFsmState::Ready
 			}
 			// Mouse up
-			(PathToolFsmState::DrawingBox, PathToolMessage::DragStop { equidistant }) => {
-				let equidistant = input.keyboard.get(equidistant as usize);
+			(PathToolFsmState::DrawingBox, PathToolMessage::DragStop { extend_selection }) => {
+				let extend_selection = input.keyboard.get(extend_selection as usize);
 
 				if tool_data.drag_start_pos == tool_data.previous_mouse_position {
 					responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![] });
 				} else {
-					shape_editor.select_all_in_quad(&document.network_interface, [tool_data.drag_start_pos, tool_data.previous_mouse_position], !equidistant);
+					shape_editor.select_all_in_quad(&document.network_interface, [tool_data.drag_start_pos, tool_data.previous_mouse_position], !extend_selection);
 				}
 				responses.add(OverlaysMessage::Draw);
 				responses.add(PathToolMessage::SelectedPointUpdated);
 
 				PathToolFsmState::Ready
 			}
-			(_, PathToolMessage::DragStop { equidistant }) => {
+			(_, PathToolMessage::DragStop { extend_selection }) => {
 				if tool_data.select_anchor_toggled {
 					shape_editor.deselect_all_points();
 					shape_editor.select_points_by_manipulator_id(&tool_data.saved_points_before_anchor_select_toggle);
@@ -651,12 +735,12 @@ impl Fsm for PathToolFsmState {
 					tool_data.select_anchor_toggled = false;
 				}
 
-				let equidistant = input.keyboard.get(equidistant as usize);
+				let extend_selection = input.keyboard.get(extend_selection as usize);
 
 				let nearest_point = shape_editor.find_nearest_point_indices(&document.network_interface, input.mouse.position, SELECTION_THRESHOLD);
 
 				if let Some((layer, nearest_point)) = nearest_point {
-					if tool_data.drag_start_pos.distance(input.mouse.position) <= DRAG_THRESHOLD && !equidistant {
+					if tool_data.drag_start_pos.distance(input.mouse.position) <= DRAG_THRESHOLD && !extend_selection {
 						let clicked_selected = shape_editor.selected_points().any(|&point| nearest_point == point);
 						if clicked_selected {
 							shape_editor.deselect_all_points();
@@ -729,6 +813,11 @@ impl Fsm for PathToolFsmState {
 				PathToolFsmState::Ready
 			}
 			(_, PathToolMessage::SelectedPointUpdated) => {
+				let colinear = shape_editor.selected_manipulator_angles(&document.network_interface);
+				tool_data.dragging_state = DraggingState {
+					point_select_state: shape_editor.get_dragging_state(&document.network_interface),
+					colinear,
+				};
 				tool_data.selection_status = get_selection_status(&document.network_interface, shape_editor);
 				self
 			}
@@ -767,19 +856,41 @@ impl Fsm for PathToolFsmState {
 					HintInfo::keys([Key::Shift], "Break Anchor").prepend_plus(),
 				]),
 			]),
-			PathToolFsmState::Dragging => HintData(vec![
-				HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()]),
-				HintGroup(vec![
-					// TODO: Switch this to the "S" key. Also, make the hint dynamically say "Make Colinear" or "Make Not Colinear" based on its current state. And only
-					// TODO: show this hint if a handle (not an anchor) is being dragged, and disable that shortcut so it can't be pressed even with the hint not shown.
-					HintInfo::keys([Key::Alt], "Toggle Colinear Handles"),
-					// TODO: Switch this to the "Alt" key (since it's equivalent to the "From Center" modifier when drawing a line). And show this only when a handle is being dragged.
-					HintInfo::keys([Key::Shift], "Equidistant Handles"),
-					// TODO: Add "Snap 15°" modifier with the "Shift" key (only when a handle is being dragged).
-					// TODO: Add "Lock Angle" modifier with the "Ctrl" key (only when a handle is being dragged).
-					HintInfo::keys([Key::Space], "Drag anchor"),
-				]),
-			]),
+			PathToolFsmState::Dragging(dragging_state) => {
+				let colinear = dragging_state.colinear;
+				let mut dragging_hint_data = HintData(Vec::new());
+				dragging_hint_data
+					.0
+					.push(HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()]));
+
+				let drag_anchor = HintInfo::keys([Key::Space], "Drag Anchor");
+				let point_select_state_hint_group = match dragging_state.point_select_state {
+					PointSelectState::HandleNoPair => vec![drag_anchor],
+					PointSelectState::HandleWithPair => {
+						let mut hints = vec![drag_anchor];
+						hints.push(HintInfo::keys([Key::Tab], "Swap Selected Handles"));
+						hints.push(HintInfo::keys(
+							[Key::KeyC],
+							if colinear == ManipulatorAngle::Colinear {
+								"Break Colinear Handles"
+							} else {
+								"Make Handles Colinear"
+							},
+						));
+						if colinear != ManipulatorAngle::Free {
+							hints.push(HintInfo::keys([Key::Alt], "Equidistant Handles"));
+						}
+						hints
+					}
+					PointSelectState::Anchor => Vec::new(),
+				};
+
+				if !point_select_state_hint_group.is_empty() {
+					dragging_hint_data.0.push(HintGroup(point_select_state_hint_group));
+				}
+
+				dragging_hint_data
+			}
 			PathToolFsmState::DrawingBox => HintData(vec![
 				HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()]),
 				HintGroup(vec![
diff --git a/editor/src/messages/tool/tool_messages/select_tool.rs b/editor/src/messages/tool/tool_messages/select_tool.rs
index b975d8e3c5..ca6a9acc21 100644
--- a/editor/src/messages/tool/tool_messages/select_tool.rs
+++ b/editor/src/messages/tool/tool_messages/select_tool.rs
@@ -71,7 +71,7 @@ pub enum SelectToolMessage {
 	Overlays(OverlayContext),
 
 	// Tool-specific messages
-	DragStart { add_to_selection: Key, select_deepest: Key },
+	DragStart { extend_selection: Key, select_deepest: Key },
 	DragStop { remove_from_selection: Key },
 	EditLayer,
 	Enter,
@@ -517,7 +517,7 @@ impl Fsm for SelectToolFsmState {
 
 				self
 			}
-			(SelectToolFsmState::Ready { .. }, SelectToolMessage::DragStart { add_to_selection, select_deepest }) => {
+			(SelectToolFsmState::Ready { .. }, SelectToolMessage::DragStart { extend_selection, select_deepest }) => {
 				tool_data.drag_start = input.mouse.position;
 				tool_data.drag_current = input.mouse.position;
 
@@ -647,7 +647,7 @@ impl Fsm for SelectToolFsmState {
 				else {
 					tool_data.layers_dragging = selected;
 
-					if !input.keyboard.key(add_to_selection) {
+					if !input.keyboard.key(extend_selection) {
 						responses.add(DocumentMessage::DeselectAllLayers);
 						tool_data.layers_dragging.clear();
 					}
diff --git a/node-graph/gcore/src/vector/vector_data.rs b/node-graph/gcore/src/vector/vector_data.rs
index f6d847ce56..35fc1db0c7 100644
--- a/node-graph/gcore/src/vector/vector_data.rs
+++ b/node-graph/gcore/src/vector/vector_data.rs
@@ -306,7 +306,7 @@ impl ManipulatorPointId {
 		}
 	}
 
-	/// Attempt to get a pair of handles. For an anchor this is the first to handles connected. For a handle it is self and the first opposing handle.
+	/// Attempt to get a pair of handles. For an anchor this is the first two handles connected. For a handle it is self and the first opposing handle.
 	#[must_use]
 	pub fn get_handle_pair(self, vector_data: &VectorData) -> Option<[HandleId; 2]> {
 		match self {


Test patch: