use super::tool_prelude::*; use crate::consts::{COLOR_OVERLAY_BLUE, DRAG_THRESHOLD, HANDLE_ROTATE_SNAP_ANGLE, INSERT_POINT_ON_SEGMENT_TOO_FAR_DISTANCE, SELECTION_THRESHOLD, SELECTION_TOLERANCE}; use crate::messages::portfolio::document::overlays::utility_functions::path_overlays; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; use crate::messages::tool::common_functionality::shape_editor::{ClosestSegment, ManipulatorAngle, OpposingHandleLengths, SelectedPointsInfo, ShapeState}; use crate::messages::tool::common_functionality::snapping::{SnapCache, SnapCandidatePoint, SnapConstraint, SnapData, SnapManager}; use graphene_core::renderer::Quad; use graphene_core::vector::ManipulatorPointId; use graphene_std::vector::NoHashBuilder; use std::vec; #[derive(Default)] pub struct PathTool { fsm_state: PathToolFsmState, tool_data: PathToolData, } #[impl_message(Message, ToolMessage, Path)] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)] pub enum PathToolMessage { // Standard messages Abort, Overlays(OverlayContext), SelectionChanged, // Tool-specific messages BreakPath, DeselectAllPoints, Delete, DeleteAndBreakPath, DragStop { extend_selection: Key, }, Enter { extend_selection: Key, }, Escape, FlipSmoothSharp, GRS { // Should be `Key::KeyG` (Grab), `Key::KeyR` (Rotate), or `Key::KeyS` (Scale) key: Key, }, ManipulatorMakeHandlesFree, ManipulatorMakeHandlesColinear, MouseDown { direct_insert_without_sliding: Key, extend_selection: Key, }, NudgeSelectedPoints { delta_x: f64, delta_y: f64, }, PointerMove { equidistant: Key, toggle_colinear: Key, move_anchor_with_handles: Key, snap_angle: Key, lock_angle: Key, }, PointerOutsideViewport { equidistant: Key, toggle_colinear: Key, move_anchor_with_handles: Key, snap_angle: Key, lock_angle: Key, }, RightClick, SelectAllAnchors, SelectedPointUpdated, SelectedPointXChanged { new_x: f64, }, SelectedPointYChanged { new_y: f64, }, SwapSelectedHandles, } impl ToolMetadata for PathTool { fn icon_name(&self) -> String { "VectorPathTool".into() } fn tooltip(&self) -> String { "Path Tool".into() } fn tool_type(&self) -> crate::messages::tool::utility_types::ToolType { ToolType::Path } } impl LayoutHolder for PathTool { fn layout(&self) -> Layout { let coordinates = self.tool_data.selection_status.as_one().as_ref().map(|point| point.coordinates); let (x, y) = coordinates.map(|point| (Some(point.x), Some(point.y))).unwrap_or((None, None)); let selection_status = &self.tool_data.selection_status; let manipulator_angle = selection_status.angle(); let x_location = NumberInput::new(x) .unit(" px") .label("X") .min_width(120) .disabled(x.is_none()) .min(-((1_u64 << f64::MANTISSA_DIGITS) as f64)) .max((1_u64 << f64::MANTISSA_DIGITS) as f64) .on_update(move |number_input: &NumberInput| { if let Some(new_x) = number_input.value.or(x) { PathToolMessage::SelectedPointXChanged { new_x }.into() } else { Message::NoOp } }) .widget_holder(); let y_location = NumberInput::new(y) .unit(" px") .label("Y") .min_width(120) .disabled(y.is_none()) .min(-((1_u64 << f64::MANTISSA_DIGITS) as f64)) .max((1_u64 << f64::MANTISSA_DIGITS) as f64) .on_update(move |number_input: &NumberInput| { if let Some(new_y) = number_input.value.or(y) { PathToolMessage::SelectedPointYChanged { new_y }.into() } else { Message::NoOp } }) .widget_holder(); let related_seperator = Separator::new(SeparatorType::Related).widget_holder(); let unrelated_seperator = Separator::new(SeparatorType::Unrelated).widget_holder(); let colinear_handles_tooltip = "Keep both handles unbent, each 180° apart, when moving either"; let colinear_handles_state = manipulator_angle.and_then(|angle| match angle { ManipulatorAngle::Colinear => Some(true), ManipulatorAngle::Free => Some(false), ManipulatorAngle::Mixed => None, }) // TODO: Remove `unwrap_or_default` once checkboxes are capable of displaying a mixed state .unwrap_or_default(); let colinear_handle_checkbox = CheckboxInput::new(colinear_handles_state) .disabled(self.tool_data.selection_status.is_none()) .on_update(|&CheckboxInput { checked, .. }| { if checked { PathToolMessage::ManipulatorMakeHandlesColinear.into() } else { PathToolMessage::ManipulatorMakeHandlesFree.into() } }) .tooltip(colinear_handles_tooltip) .widget_holder(); let colinear_handles_label = TextLabel::new("Colinear Handles") .disabled(self.tool_data.selection_status.is_none()) .tooltip(colinear_handles_tooltip) .widget_holder(); Layout::WidgetLayout(WidgetLayout::new(vec![LayoutGroup::Row { widgets: vec![ x_location, related_seperator.clone(), y_location, unrelated_seperator, colinear_handle_checkbox, related_seperator, colinear_handles_label, ], }])) } } impl<'a> MessageHandler> for PathTool { fn process_message(&mut self, message: ToolMessage, responses: &mut VecDeque, tool_data: &mut ToolActionHandlerData<'a>) { let updating_point = message == ToolMessage::Path(PathToolMessage::SelectedPointUpdated); 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); } } // Different actions depending on state may be wanted: fn actions(&self) -> ActionList { match self.fsm_state { PathToolFsmState::Ready => actions!(PathToolMessageDiscriminant; FlipSmoothSharp, MouseDown, Delete, NudgeSelectedPoints, Enter, SelectAllAnchors, DeselectAllPoints, BreakPath, DeleteAndBreakPath, ), PathToolFsmState::Dragging(_) => actions!(PathToolMessageDiscriminant; Escape, RightClick, FlipSmoothSharp, DragStop, PointerMove, Delete, BreakPath, DeleteAndBreakPath, SwapSelectedHandles, ), PathToolFsmState::DrawingBox => actions!(PathToolMessageDiscriminant; FlipSmoothSharp, DragStop, PointerMove, Delete, Enter, BreakPath, DeleteAndBreakPath, Escape, RightClick, ), PathToolFsmState::InsertPoint => actions!(PathToolMessageDiscriminant; Enter, MouseDown, PointerMove, Escape, Delete, RightClick, GRS, ), } } } impl ToolTransition for PathTool { fn event_to_message_map(&self) -> EventToMessageMap { EventToMessageMap { tool_abort: Some(PathToolMessage::Abort.into()), selection_changed: Some(PathToolMessage::SelectionChanged.into()), overlay_provider: Some(|overlay_context| PathToolMessage::Overlays(overlay_context).into()), ..Default::default() } } } #[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(DraggingState), DrawingBox, InsertPoint, } enum InsertEndKind { Abort, Add { extend_selection: bool }, } #[derive(Default)] struct PathToolData { snap_manager: SnapManager, drag_start_pos: DVec2, previous_mouse_position: DVec2, toggle_colinear_debounce: bool, opposing_handle_lengths: Option, /// 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. selection_status: SelectionStatus, segment: Option, snap_cache: SnapCache, double_click_handled: bool, auto_panning: AutoPanning, saved_points_before_anchor_select_toggle: Vec, select_anchor_toggled: bool, dragging_state: DraggingState, current_selected_handle_id: Option, angle: f64, } impl PathToolData { fn save_points_before_anchor_toggle(&mut self, points: Vec) -> PathToolFsmState { self.saved_points_before_anchor_select_toggle = points; PathToolFsmState::Dragging(self.dragging_state) } fn remove_saved_points(&mut self) { self.saved_points_before_anchor_select_toggle.clear(); } fn start_insertion(&mut self, responses: &mut VecDeque, segment: ClosestSegment) -> PathToolFsmState { if self.segment.is_some() { warn!("Segment was `Some(..)` before `start_insertion`") } self.segment = Some(segment); responses.add(OverlaysMessage::Draw); PathToolFsmState::InsertPoint } fn update_insertion(&mut self, shape_editor: &mut ShapeState, document: &DocumentMessageHandler, responses: &mut VecDeque, input: &InputPreprocessorMessageHandler) -> PathToolFsmState { if let Some(closed_segment) = &mut self.segment { closed_segment.update_closest_point(document.metadata(), input.mouse.position); if closed_segment.too_far(input.mouse.position, INSERT_POINT_ON_SEGMENT_TOO_FAR_DISTANCE, document.metadata()) { self.end_insertion(shape_editor, responses, InsertEndKind::Abort) } else { PathToolFsmState::InsertPoint } } else { warn!("Segment was `None` on `update_insertion`"); PathToolFsmState::Ready } } fn end_insertion(&mut self, shape_editor: &mut ShapeState, responses: &mut VecDeque, kind: InsertEndKind) -> PathToolFsmState { let mut commit_transaction = false; match self.segment.as_mut() { None => { warn!("Segment was `None` before `end_insertion`") } Some(closed_segment) => { if let InsertEndKind::Add { extend_selection } = kind { closed_segment.adjusted_insert_and_select(shape_editor, responses, extend_selection); commit_transaction = true; } } } self.segment = None; if commit_transaction { responses.add(DocumentMessage::EndTransaction); } else { responses.add(DocumentMessage::AbortTransaction); } responses.add(OverlaysMessage::Draw); PathToolFsmState::Ready } fn mouse_down( &mut self, shape_editor: &mut ShapeState, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque, extend_selection: bool, direct_insert_without_sliding: bool, ) -> PathToolFsmState { self.double_click_handled = false; self.opposing_handle_lengths = None; 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, extend_selection) { responses.add(DocumentMessage::StartTransaction); if let Some(selected_points) = selected_points { self.drag_start_pos = input.mouse.position; self.start_dragging_point(selected_points, input, document, shape_editor); responses.add(OverlaysMessage::Draw); } 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 { 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 extend_selection { responses.add(NodeGraphMessage::SelectedNodesAdd { nodes: vec![layer.to_node()] }); } else { responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }); } self.drag_start_pos = input.mouse.position; self.previous_mouse_position = document.metadata().document_to_viewport.inverse().transform_point2(input.mouse.position); responses.add(DocumentMessage::StartTransaction); PathToolFsmState::Dragging(self.dragging_state) } // Start drawing a box else { self.drag_start_pos = input.mouse.position; self.previous_mouse_position = document.metadata().document_to_viewport.inverse().transform_point2(input.mouse.position); PathToolFsmState::DrawingBox } } fn start_dragging_point(&mut self, selected_points: SelectedPointsInfo, input: &InputPreprocessorMessageHandler, document: &DocumentMessageHandler, shape_editor: &mut ShapeState) { let mut manipulators = HashMap::with_hasher(NoHashBuilder); let mut unselected = Vec::new(); for (&layer, state) in &shape_editor.selected_shape_state { let Some(vector_data) = document.network_interface.compute_modified_vector(layer) else { continue; }; let transform = document.metadata().transform_to_document(layer); let mut layer_manipulators = HashSet::with_hasher(NoHashBuilder); for point in state.selected() { let Some(anchor) = point.get_anchor(&vector_data) else { continue }; layer_manipulators.insert(anchor); } for (&id, &position) in vector_data.point_domain.ids().iter().zip(vector_data.point_domain.positions()) { if layer_manipulators.contains(&id) { continue; } unselected.push(SnapCandidatePoint::handle(transform.transform_point2(position))) } if !layer_manipulators.is_empty() { manipulators.insert(layer, layer_manipulators); } } self.snap_cache = SnapCache { manipulators, unselected }; let viewport_to_document = document.metadata().document_to_viewport.inverse(); self.previous_mouse_position = viewport_to_document.transform_point2(input.mouse.position - selected_points.offset); } fn update_colinear(&mut self, equidistant: bool, toggle_colinear: bool, shape_editor: &mut ShapeState, document: &DocumentMessageHandler, responses: &mut VecDeque) -> bool { // Check handle colinear state let is_colinear = self .selection_status .angle() .map(|angle| match angle { ManipulatorAngle::Colinear => true, ManipulatorAngle::Free | ManipulatorAngle::Mixed => false, }) .unwrap_or(false); // Check if the toggle_colinear key has just been pressed if toggle_colinear && !self.toggle_colinear_debounce { self.opposing_handle_lengths = None; if is_colinear { shape_editor.disable_colinear_handles_state_on_selected(&document.network_interface, responses); } else { shape_editor.convert_selected_manipulators_to_colinear_handles(responses, document); } self.toggle_colinear_debounce = true; return true; } self.toggle_colinear_debounce = toggle_colinear; if equidistant && self.opposing_handle_lengths.is_none() { if !is_colinear { // Try to get selected handle info let Some((_, _, selected_handle_id)) = self.try_get_selected_handle_and_anchor(shape_editor, document) else { self.opposing_handle_lengths = Some(shape_editor.opposing_handle_lengths(document)); return false; }; let Some((layer, _)) = shape_editor.selected_shape_state.iter().next() else { self.opposing_handle_lengths = Some(shape_editor.opposing_handle_lengths(document)); return false; }; let Some(vector_data) = document.network_interface.compute_modified_vector(*layer) else { self.opposing_handle_lengths = Some(shape_editor.opposing_handle_lengths(document)); return false; }; // Check if handle has a pair (to ignore handles of edges of open paths) if let Some(handle_pair) = selected_handle_id.get_handle_pair(&vector_data) { let opposite_handle_length = handle_pair.iter().filter(|&&h| h.to_manipulator_point() != selected_handle_id).find_map(|&h| { let opp_handle_pos = h.to_manipulator_point().get_position(&vector_data)?; let opp_anchor_id = h.to_manipulator_point().get_anchor(&vector_data)?; let opp_anchor_pos = vector_data.point_domain.position_from_id(opp_anchor_id)?; Some((opp_handle_pos - opp_anchor_pos).length()) }); // Make handles colinear if opposite handle is zero length if opposite_handle_length.map_or(false, |l| l == 0.) { shape_editor.convert_selected_manipulators_to_colinear_handles(responses, document); return true; } } } self.opposing_handle_lengths = Some(shape_editor.opposing_handle_lengths(document)); } false } /// Attempts to get a single selected handle. Also retrieves the position of the anchor it is connected to. Used for the purpose of snapping the angle. fn try_get_selected_handle_and_anchor(&self, shape_editor: &ShapeState, document: &DocumentMessageHandler) -> Option<(DVec2, DVec2, ManipulatorPointId)> { // Only count selections of a single layer let (layer, selection) = shape_editor.selected_shape_state.iter().next()?; // Do not allow selections of multiple points to count if selection.selected_points_count() != 1 { return None; } // Only count selected handles let selected_handle = selection.selected().next()?.as_handle()?; let handle_id = selected_handle.to_manipulator_point(); let layer_to_document = document.metadata().transform_to_document(*layer); let vector_data = document.network_interface.compute_modified_vector(*layer)?; let handle_position_local = selected_handle.to_manipulator_point().get_position(&vector_data)?; let anchor_id = selected_handle.to_manipulator_point().get_anchor(&vector_data)?; let anchor_position_local = vector_data.point_domain.position_from_id(anchor_id)?; let handle_position_document = layer_to_document.transform_point2(handle_position_local); let anchor_position_document = layer_to_document.transform_point2(anchor_position_local); Some((handle_position_document, anchor_position_document, handle_id)) } fn calculate_handle_angle(&mut self, handle_vector: DVec2, handle_id: ManipulatorPointId, lock_angle: bool, snap_angle: bool) -> f64 { let current_angle = -handle_vector.angle_to(DVec2::X); // When the angle is locked we use the old angle if self.current_selected_handle_id == Some(handle_id) && lock_angle { return self.angle; } // Round the angle to the closest increment let mut handle_angle = current_angle; if snap_angle && !lock_angle { let snap_resolution = HANDLE_ROTATE_SNAP_ANGLE.to_radians(); handle_angle = (handle_angle / snap_resolution).round() * snap_resolution; } // Cache the angle and handle id for lock angle self.current_selected_handle_id = Some(handle_id); self.angle = handle_angle; handle_angle } #[allow(clippy::too_many_arguments)] fn apply_snapping( &mut self, handle_direction: DVec2, new_handle_position: DVec2, anchor_position: DVec2, using_angle_constraints: bool, handle_position: DVec2, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, ) -> DVec2 { let snap_data = SnapData::new(document, input); let snap_point = SnapCandidatePoint::handle_neighbors(new_handle_position, [anchor_position]); let snap_result = match using_angle_constraints { true => { let snap_constraint = SnapConstraint::Line { origin: anchor_position, direction: handle_direction.normalize_or_zero(), }; self.snap_manager.constrained_snap(&snap_data, &snap_point, snap_constraint, Default::default()) } false => self.snap_manager.free_snap(&snap_data, &snap_point, Default::default()), }; self.snap_manager.update_indicator(snap_result.clone()); document.metadata().document_to_viewport.transform_vector2(snap_result.snapped_point_document - handle_position) } #[allow(clippy::too_many_arguments)] fn drag( &mut self, equidistant: bool, lock_angle: bool, snap_angle: bool, shape_editor: &mut ShapeState, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque, ) { let document_to_viewport = document.metadata().document_to_viewport; let previous_mouse = document_to_viewport.transform_point2(self.previous_mouse_position); let current_mouse = input.mouse.position; let raw_delta = document_to_viewport.inverse().transform_vector2(current_mouse - previous_mouse); let snapped_delta = if let Some((handle_pos, anchor_pos, handle_id)) = self.try_get_selected_handle_and_anchor(shape_editor, document) { let cursor_pos = handle_pos + raw_delta; let handle_angle = self.calculate_handle_angle(cursor_pos - anchor_pos, handle_id, lock_angle, snap_angle); let constrained_direction = DVec2::new(handle_angle.cos(), handle_angle.sin()); let projected_length = (cursor_pos - anchor_pos).dot(constrained_direction); let constrained_target = anchor_pos + constrained_direction * projected_length; let constrained_delta = constrained_target - handle_pos; self.apply_snapping(constrained_direction, handle_pos + constrained_delta, anchor_pos, lock_angle || snap_angle, handle_pos, document, input) } else { shape_editor.snap(&mut self.snap_manager, &self.snap_cache, document, input, previous_mouse) }; let handle_lengths = if equidistant { None } else { self.opposing_handle_lengths.take() }; shape_editor.move_selected_points(handle_lengths, document, snapped_delta, equidistant, responses, true); self.previous_mouse_position += document_to_viewport.inverse().transform_vector2(snapped_delta); } } impl Fsm for PathToolFsmState { type ToolData = PathToolData; type ToolOptions = (); fn transition(self, event: ToolMessage, tool_data: &mut Self::ToolData, tool_action_data: &mut ToolActionHandlerData, _tool_options: &(), responses: &mut VecDeque) -> Self { let ToolActionHandlerData { document, input, shape_editor, .. } = tool_action_data; let ToolMessage::Path(event) = event else { return self }; match (self, event) { (_, PathToolMessage::SelectionChanged) => { // Set the newly targeted layers to visible let target_layers = document.network_interface.selected_nodes(&[]).unwrap().selected_layers(document.metadata()).collect(); shape_editor.set_selected_layers(target_layers); responses.add(OverlaysMessage::Draw); responses.add(PathToolMessage::SelectedPointUpdated); self } (_, PathToolMessage::Overlays(mut overlay_context)) => { path_overlays(document, shape_editor, &mut overlay_context); match self { Self::DrawingBox => { let fill_color = graphene_std::Color::from_rgb_str(crate::consts::COLOR_OVERLAY_BLUE.strip_prefix('#').unwrap()) .unwrap() .with_alpha(0.05) .rgba_hex(); overlay_context.quad(Quad::from_box([tool_data.drag_start_pos, tool_data.previous_mouse_position]), Some(&("#".to_string() + &fill_color))); } Self::Dragging(_) => { tool_data.snap_manager.draw_overlays(SnapData::new(document, input), &mut overlay_context); } Self::InsertPoint => { let state = tool_data.update_insertion(shape_editor, document, responses, input); if let Some(closest_segment) = &tool_data.segment { overlay_context.manipulator_anchor(closest_segment.closest_point_to_viewport(), false, Some(COLOR_OVERLAY_BLUE)); if let (Some(handle1), Some(handle2)) = closest_segment.handle_positions(document.metadata()) { overlay_context.line(closest_segment.closest_point_to_viewport(), handle1, Some(COLOR_OVERLAY_BLUE)); overlay_context.line(closest_segment.closest_point_to_viewport(), handle2, Some(COLOR_OVERLAY_BLUE)); overlay_context.manipulator_handle(handle1, false, Some(COLOR_OVERLAY_BLUE)); overlay_context.manipulator_handle(handle2, false, Some(COLOR_OVERLAY_BLUE)); } } responses.add(PathToolMessage::SelectedPointUpdated); return state; } _ => {} } responses.add(PathToolMessage::SelectedPointUpdated); self } // `Self::InsertPoint` case: (Self::InsertPoint, PathToolMessage::MouseDown { extend_selection, .. } | PathToolMessage::Enter { extend_selection }) => { tool_data.double_click_handled = true; 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); // `tool_data.update_insertion` would be called on `OverlaysMessage::Draw` // we anyway should to call it on `::Draw` because we can change scale by ctrl+scroll without `::PointerMove` self } (Self::InsertPoint, PathToolMessage::Escape | PathToolMessage::Delete | PathToolMessage::RightClick) => tool_data.end_insertion(shape_editor, responses, InsertEndKind::Abort), (Self::InsertPoint, PathToolMessage::GRS { key: _ }) => PathToolFsmState::InsertPoint, // Mouse down ( _, 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, snap_angle, lock_angle, }, ) => { tool_data.previous_mouse_position = input.mouse.position; responses.add(OverlaysMessage::Draw); // Auto-panning let messages = [ PathToolMessage::PointerOutsideViewport { equidistant, toggle_colinear, move_anchor_with_handles, snap_angle, lock_angle, } .into(), PathToolMessage::PointerMove { equidistant, toggle_colinear, move_anchor_with_handles, snap_angle, lock_angle, } .into(), ]; tool_data.auto_panning.setup_by_mouse_position(input, &messages, responses); PathToolFsmState::DrawingBox } ( PathToolFsmState::Dragging(_), PathToolMessage::PointerMove { equidistant, toggle_colinear, move_anchor_with_handles, snap_angle, lock_angle, }, ) => { if tool_data.selection_status.is_none() { if let Some(layer) = document.click(input) { shape_editor.select_all_anchors_in_layer(document, layer); } } 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; if initial_press { responses.add(PathToolMessage::SelectedPointUpdated); tool_data.select_anchor_toggled = true; tool_data.save_points_before_anchor_toggle(shape_editor.selected_points().cloned().collect()); shape_editor.select_handles_and_anchor_connected_to_current_handle(&document.network_interface); } else if released_from_toggle { responses.add(PathToolMessage::SelectedPointUpdated); tool_data.select_anchor_toggled = false; shape_editor.deselect_all_points(); shape_editor.select_points_by_manipulator_id(&tool_data.saved_points_before_anchor_select_toggle); tool_data.remove_saved_points(); } let toggle_colinear_state = input.keyboard.get(toggle_colinear as usize); let equidistant_state = input.keyboard.get(equidistant as usize); let lock_angle_state = input.keyboard.get(lock_angle as usize); let snap_angle_state = input.keyboard.get(snap_angle as usize); if !tool_data.update_colinear(equidistant_state, toggle_colinear_state, tool_action_data.shape_editor, tool_action_data.document, responses) { tool_data.drag( equidistant_state, lock_angle_state, snap_angle_state, tool_action_data.shape_editor, tool_action_data.document, input, responses, ); } // Auto-panning let messages = [ PathToolMessage::PointerOutsideViewport { toggle_colinear, equidistant, move_anchor_with_handles, snap_angle, lock_angle, } .into(), PathToolMessage::PointerMove { toggle_colinear, equidistant, move_anchor_with_handles, snap_angle, lock_angle, } .into(), ]; tool_data.auto_panning.setup_by_mouse_position(input, &messages, responses); PathToolFsmState::Dragging(tool_data.dragging_state) } (PathToolFsmState::DrawingBox, PathToolMessage::PointerOutsideViewport { .. }) => { // Auto-panning if let Some(offset) = tool_data.auto_panning.shift_viewport(input, responses) { tool_data.drag_start_pos += offset; } PathToolFsmState::DrawingBox } ( PathToolFsmState::Dragging(dragging_state), PathToolMessage::PointerOutsideViewport { equidistant, snap_angle, lock_angle, .. }, ) => { // Auto-panning if tool_data.auto_panning.shift_viewport(input, responses).is_some() { let equidistant = input.keyboard.get(equidistant as usize); let snap_angle = input.keyboard.get(snap_angle as usize); let lock_angle = input.keyboard.get(lock_angle as usize); tool_data.drag(equidistant, lock_angle, snap_angle, shape_editor, document, input, responses); } PathToolFsmState::Dragging(dragging_state) } ( state, PathToolMessage::PointerOutsideViewport { equidistant, toggle_colinear, move_anchor_with_handles, snap_angle, lock_angle, }, ) => { // Auto-panning let messages = [ PathToolMessage::PointerOutsideViewport { equidistant, toggle_colinear, move_anchor_with_handles, snap_angle, lock_angle, } .into(), PathToolMessage::PointerMove { equidistant, toggle_colinear, move_anchor_with_handles, snap_angle, lock_angle, } .into(), ]; tool_data.auto_panning.stop(&messages, responses); state } (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], !extend_selection); } responses.add(OverlaysMessage::Draw); PathToolFsmState::Ready } (PathToolFsmState::Dragging { .. }, PathToolMessage::Escape | PathToolMessage::RightClick) => { responses.add(DocumentMessage::AbortTransaction); tool_data.snap_manager.cleanup(responses); PathToolFsmState::Ready } (PathToolFsmState::DrawingBox, PathToolMessage::Escape | PathToolMessage::RightClick) => { tool_data.snap_manager.cleanup(responses); PathToolFsmState::Ready } // Mouse up (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], !extend_selection); } responses.add(OverlaysMessage::Draw); responses.add(PathToolMessage::SelectedPointUpdated); PathToolFsmState::Ready } (_, 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); tool_data.remove_saved_points(); tool_data.select_anchor_toggled = false; } 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 && !extend_selection { let clicked_selected = shape_editor.selected_points().any(|&point| nearest_point == point); if clicked_selected { shape_editor.deselect_all_points(); shape_editor.selected_shape_state.entry(layer).or_default().select_point(nearest_point); responses.add(OverlaysMessage::Draw); } } } // Deselect all points if the user clicks the filled region of the shape else if tool_data.drag_start_pos.distance(input.mouse.position) <= DRAG_THRESHOLD { shape_editor.deselect_all_points(); } responses.add(DocumentMessage::EndTransaction); responses.add(PathToolMessage::SelectedPointUpdated); tool_data.snap_manager.cleanup(responses); PathToolFsmState::Ready } // Delete key (_, PathToolMessage::Delete) => { // Delete the selected points and clean up overlays responses.add(DocumentMessage::AddTransaction); shape_editor.delete_selected_points(document, responses); responses.add(PathToolMessage::SelectionChanged); PathToolFsmState::Ready } (_, PathToolMessage::BreakPath) => { shape_editor.break_path_at_selected_point(document, responses); PathToolFsmState::Ready } (_, PathToolMessage::DeleteAndBreakPath) => { shape_editor.delete_point_and_break_path(document, responses); PathToolFsmState::Ready } (_, PathToolMessage::FlipSmoothSharp) => { // Double-clicked on a point let nearest_point = shape_editor.find_nearest_point_indices(&document.network_interface, input.mouse.position, SELECTION_THRESHOLD); if nearest_point.is_some() { // Flip the selected point between smooth and sharp if !tool_data.double_click_handled && tool_data.drag_start_pos.distance(input.mouse.position) <= DRAG_THRESHOLD { responses.add(DocumentMessage::StartTransaction); shape_editor.flip_smooth_sharp(&document.network_interface, input.mouse.position, SELECTION_TOLERANCE, responses); responses.add(DocumentMessage::EndTransaction); responses.add(PathToolMessage::SelectedPointUpdated); } return PathToolFsmState::Ready; } // Double-clicked on a filled region if let Some(layer) = document.click(input) { // Select all points in the layer shape_editor.select_connected_anchors(document, layer, input.mouse.position); } PathToolFsmState::Ready } (_, PathToolMessage::Abort) => { responses.add(OverlaysMessage::Draw); PathToolFsmState::Ready } (_, PathToolMessage::PointerMove { .. }) => self, (_, PathToolMessage::NudgeSelectedPoints { delta_x, delta_y }) => { shape_editor.move_selected_points(tool_data.opposing_handle_lengths.take(), document, (delta_x, delta_y).into(), true, responses, false); PathToolFsmState::Ready } (_, PathToolMessage::SelectAllAnchors) => { shape_editor.select_all_anchors_in_selected_layers(document); responses.add(OverlaysMessage::Draw); PathToolFsmState::Ready } (_, PathToolMessage::DeselectAllPoints) => { shape_editor.deselect_all_points(); responses.add(OverlaysMessage::Draw); PathToolFsmState::Ready } (_, PathToolMessage::SelectedPointXChanged { new_x }) => { if let Some(&SingleSelectedPoint { coordinates, id, layer, .. }) = tool_data.selection_status.as_one() { shape_editor.reposition_control_point(&id, &document.network_interface, DVec2::new(new_x, coordinates.y), layer, responses); } PathToolFsmState::Ready } (_, PathToolMessage::SelectedPointYChanged { new_y }) => { if let Some(&SingleSelectedPoint { coordinates, id, layer, .. }) = tool_data.selection_status.as_one() { shape_editor.reposition_control_point(&id, &document.network_interface, DVec2::new(coordinates.x, new_y), layer, responses); } 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 } (_, PathToolMessage::ManipulatorMakeHandlesColinear) => { responses.add(DocumentMessage::StartTransaction); shape_editor.convert_selected_manipulators_to_colinear_handles(responses, document); responses.add(DocumentMessage::EndTransaction); responses.add(PathToolMessage::SelectionChanged); PathToolFsmState::Ready } (_, PathToolMessage::ManipulatorMakeHandlesFree) => { responses.add(DocumentMessage::StartTransaction); shape_editor.disable_colinear_handles_state_on_selected(&document.network_interface, responses); responses.add(DocumentMessage::EndTransaction); PathToolFsmState::Ready } (_, _) => PathToolFsmState::Ready, } } fn update_hints(&self, responses: &mut VecDeque) { let hint_data = match self { PathToolFsmState::Ready => HintData(vec![ HintGroup(vec![HintInfo::mouse(MouseMotion::Lmb, "Select Point"), HintInfo::keys([Key::Shift], "Extend Selection").prepend_plus()]), HintGroup(vec![HintInfo::mouse(MouseMotion::Lmb, "Insert Point on Segment")]), // TODO: Only show if at least one anchor is selected, and dynamically show either "Smooth" or "Sharp" based on the current state 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::arrow_keys("Nudge Selected"), HintInfo::keys([Key::Shift], "10x").prepend_plus()]), HintGroup(vec![ HintInfo::keys([Key::Delete], "Delete Selected"), // TODO: Only show the following hints if at least one anchor is selected HintInfo::keys([Key::Accel], "No Dissolve").prepend_plus(), HintInfo::keys([Key::Shift], "Cut Anchor").prepend_plus(), ]), ]), 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 => { let mut hints = vec![drag_anchor]; hints.push(HintInfo::keys([Key::Shift], "Snap 15°")); hints.push(HintInfo::keys([Key::Control], "Lock Angle")); hints } 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.push(HintInfo::keys([Key::Shift], "Snap 15°")); hints.push(HintInfo::keys([Key::Control], "Lock Angle")); 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![ HintInfo::mouse(MouseMotion::LmbDrag, "Select Area"), HintInfo::keys([Key::Shift], "Extend Selection").prepend_plus(), ]), ]), PathToolFsmState::InsertPoint => HintData(vec![ HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()]), HintGroup(vec![HintInfo::mouse(MouseMotion::Lmb, "Insert Point")]), ]), }; responses.add(FrontendMessage::UpdateInputHints { hint_data }); } fn update_cursor(&self, responses: &mut VecDeque) { responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::Default }); } } #[derive(Debug, PartialEq, Default)] enum SelectionStatus { #[default] None, One(SingleSelectedPoint), Multiple(MultipleSelectedPoints), } impl SelectionStatus { fn is_none(&self) -> bool { self == &SelectionStatus::None } fn as_one(&self) -> Option<&SingleSelectedPoint> { match self { SelectionStatus::One(one) => Some(one), _ => None, } } fn angle(&self) -> Option { match self { Self::None => None, Self::One(one) => Some(one.manipulator_angle), Self::Multiple(one) => Some(one.manipulator_angle), } } } #[derive(Debug, PartialEq)] struct MultipleSelectedPoints { manipulator_angle: ManipulatorAngle, } #[derive(Debug, PartialEq)] struct SingleSelectedPoint { coordinates: DVec2, id: ManipulatorPointId, layer: LayerNodeIdentifier, manipulator_angle: ManipulatorAngle, } /// Sets the cumulative description of the selected points: if `None` are selected, if `One` is selected, or if `Multiple` are selected. /// Applies to any selected points, whether they are anchors or handles; and whether they are from a single shape or across multiple shapes. fn get_selection_status(network_interface: &NodeNetworkInterface, shape_state: &mut ShapeState) -> SelectionStatus { let mut selection_layers = shape_state.selected_shape_state.iter().map(|(k, v)| (*k, v.selected_points_count())); let total_selected_points = selection_layers.clone().map(|(_, v)| v).sum::(); // Check to see if only one manipulator group in a single shape is selected if total_selected_points == 1 { let Some(layer) = selection_layers.find(|(_, v)| *v > 0).map(|(k, _)| k) else { return SelectionStatus::None; }; let Some(vector_data) = network_interface.compute_modified_vector(layer) else { return SelectionStatus::None; }; let Some(&point) = shape_state.selected_points().next() else { return SelectionStatus::None; }; let Some(local_position) = point.get_position(&vector_data) else { return SelectionStatus::None; }; let coordinates = network_interface.document_metadata().transform_to_document(layer).transform_point2(local_position); let manipulator_angle = if vector_data.colinear(point) { ManipulatorAngle::Colinear } else { ManipulatorAngle::Free }; return SelectionStatus::One(SingleSelectedPoint { coordinates, layer, id: point, manipulator_angle, }); }; // Check to see if multiple manipulator groups are selected if total_selected_points > 1 { return SelectionStatus::Multiple(MultipleSelectedPoints { manipulator_angle: shape_state.selected_manipulator_angles(network_interface), }); } SelectionStatus::None }