diff --git a/editor/src/messages/input_mapper/input_mappings.rs b/editor/src/messages/input_mapper/input_mappings.rs index ece19b1c..b9bb234d 100644 --- a/editor/src/messages/input_mapper/input_mappings.rs +++ b/editor/src/messages/input_mapper/input_mappings.rs @@ -177,7 +177,7 @@ pub fn input_mappings(zoom_with_scroll: bool) -> Mapping { // GradientToolMessage entry!(DoubleClick(MouseButton::Left); action_dispatch=GradientToolMessage::DoubleClick), entry!(KeyDown(MouseLeft); action_dispatch=GradientToolMessage::PointerDown), - entry!(PointerMove; refresh_keys=[Shift], action_dispatch=GradientToolMessage::PointerMove { constrain_axis: Shift }), + entry!(PointerMove; refresh_keys=[Shift, Control], action_dispatch=GradientToolMessage::PointerMove { constrain_axis: Shift, lock_angle: Control }), entry!(KeyUp(MouseLeft); action_dispatch=GradientToolMessage::PointerUp), entry!(KeyDown(Delete); action_dispatch=GradientToolMessage::DeleteStop), entry!(KeyDown(Backspace); action_dispatch=GradientToolMessage::DeleteStop), diff --git a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs index 433a282b..a0c459c8 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs @@ -2787,7 +2787,10 @@ impl NodeGraphMessageHandler { if self.has_selection { hint_data.0.extend([ HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDrag, "Drag Selected")]), - HintGroup(vec![HintInfo::keys([Key::Delete], "Delete Selected"), HintInfo::keys([Key::Control], "Keep Children").prepend_plus()]), + HintGroup(vec![ + HintInfo::keys([Key::Backspace], "Delete Selected"), + HintInfo::keys([Key::Control], "Keep Children").prepend_plus(), + ]), HintGroup(vec![ HintInfo::keys_and_mouse([Key::Alt], MouseMotion::LmbDrag, "Move Duplicate"), HintInfo::keys([Key::Control, Key::KeyD], "Duplicate").add_mac_keys([Key::Command, Key::KeyD]), diff --git a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs index 73b1f423..c5d3d968 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs @@ -33,6 +33,14 @@ pub fn empty_provider() -> OverlayProvider { |_| Message::NoOp } +// TODO Remove duplicated definition of this in `utility_types_web.rs` +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GizmoEmphasis { + Regular, + Hovered, + Active, +} + // TODO Remove duplicated definition of this in `utility_types_web.rs` /// Types of overlays used by DocumentMessage to enable/disable the selected set of viewport overlays. #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)] @@ -273,12 +281,12 @@ impl OverlayContext { self.internal().manipulator_anchor(position, selected, color); } - pub fn gradient_color_stop(&mut self, position: DVec2, selected: bool, color: &str, small: bool) { - self.internal().gradient_color_stop(position, selected, color, small); + pub fn gradient_color_stop(&mut self, position: DVec2, emphasis: GizmoEmphasis, color: &str, small: bool) { + self.internal().gradient_color_stop(position, emphasis, color, small); } - pub fn gradient_midpoint(&mut self, position: DVec2, selected: bool, angle: f64) { - self.internal().gradient_midpoint(position, selected, angle); + pub fn gradient_midpoint(&mut self, position: DVec2, emphasis: GizmoEmphasis, angle: f64) { + self.internal().gradient_midpoint(position, emphasis, angle); } pub fn resize_handle(&mut self, position: DVec2, rotation: f64) { @@ -591,11 +599,16 @@ impl OverlayContextInternal { self.square(position, None, Some(color_fill), Some(COLOR_OVERLAY_BLUE)); } - fn gradient_color_stop(&mut self, position: DVec2, selected: bool, color: &str, small: bool) { + fn gradient_color_stop(&mut self, position: DVec2, emphasis: GizmoEmphasis, color: &str, small: bool) { let transform = self.get_transform(); let position = position.round() - DVec2::splat(0.5); - let (radius_offset, stroke_width) = if selected { (1., 3.) } else { (0., 1.) }; + let radius_offset = match emphasis { + GizmoEmphasis::Regular => 0., + GizmoEmphasis::Hovered => 0.5, + GizmoEmphasis::Active => 1., + }; + let stroke_width = radius_offset * 2. + 1.; let radius = (MANIPULATOR_GROUP_MARKER_SIZE / 1.5 + 1. + radius_offset) * if small { 0.5 } else { 1. }; let mut draw_circle = |radius: f64, width: Option, color: &str| { @@ -614,7 +627,7 @@ impl OverlayContextInternal { draw_circle(radius, Some(stroke_width), COLOR_OVERLAY_WHITE); } - fn gradient_midpoint(&mut self, position: DVec2, selected: bool, angle: f64) { + fn gradient_midpoint(&mut self, position: DVec2, emphasis: GizmoEmphasis, angle: f64) { let transform = self.get_transform(); let position = position.round() - DVec2::splat(0.5); @@ -634,9 +647,13 @@ impl OverlayContextInternal { path.line_to(kurbo::Point::new(position.x + left.x, position.y + left.y)); path.close_path(); - let fill = if selected { COLOR_OVERLAY_BLUE } else { COLOR_OVERLAY_WHITE }; + let (fill, stroke_width) = match emphasis { + GizmoEmphasis::Regular => (COLOR_OVERLAY_WHITE, 1.), + GizmoEmphasis::Hovered => (COLOR_OVERLAY_WHITE, 2.), + GizmoEmphasis::Active => (COLOR_OVERLAY_BLUE, 1.), + }; self.scene.fill(peniko::Fill::NonZero, transform, Self::parse_color(fill), None, &path); - self.scene.stroke(&kurbo::Stroke::new(1.), transform, Self::parse_color(COLOR_OVERLAY_BLUE), None, &path); + self.scene.stroke(&kurbo::Stroke::new(stroke_width), transform, Self::parse_color(COLOR_OVERLAY_BLUE), None, &path); } fn resize_handle(&mut self, position: DVec2, rotation: f64) { diff --git a/editor/src/messages/portfolio/document/overlays/utility_types_web.rs b/editor/src/messages/portfolio/document/overlays/utility_types_web.rs index 811104c7..43757cf5 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_web.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_web.rs @@ -28,6 +28,13 @@ pub fn empty_provider() -> OverlayProvider { |_| Message::NoOp } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GizmoEmphasis { + Regular, + Hovered, + Active, +} + /// Types of overlays used by DocumentMessage to enable/disable the selected set of viewport overlays. #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)] pub enum OverlaysType { @@ -469,12 +476,17 @@ impl OverlayContext { self.square(position, None, Some(color_fill), Some(color_stroke)); } - pub fn gradient_color_stop(&mut self, position: DVec2, selected: bool, color: &str, small: bool) { + pub fn gradient_color_stop(&mut self, position: DVec2, emphasis: GizmoEmphasis, color: &str, small: bool) { self.start_dpi_aware_transform(); let position = position.round() - DVec2::splat(0.5); - let (radius_offset, stroke_width) = if selected { (1., 3.) } else { (0., 1.) }; + let radius_offset = match emphasis { + GizmoEmphasis::Regular => 0., + GizmoEmphasis::Hovered => 0.5, + GizmoEmphasis::Active => 1., + }; + let stroke_width = radius_offset * 2. + 1.; let radius = (MANIPULATOR_GROUP_MARKER_SIZE / 1.5 + 1. + radius_offset) * if small { 0.5 } else { 1. }; let draw_circle = |radius: f64, width: Option, color: &str| { @@ -501,7 +513,7 @@ impl OverlayContext { self.end_dpi_aware_transform(); } - pub fn gradient_midpoint(&mut self, position: DVec2, selected: bool, angle: f64) { + pub fn gradient_midpoint(&mut self, position: DVec2, emphasis: GizmoEmphasis, angle: f64) { self.start_dpi_aware_transform(); let position = position.round() - DVec2::splat(0.5); @@ -522,11 +534,17 @@ impl OverlayContext { self.render_context.line_to(position.x + left.x, position.y + left.y); self.render_context.close_path(); - let fill = if selected { COLOR_OVERLAY_BLUE } else { COLOR_OVERLAY_WHITE }; + let (fill, stroke_width) = match emphasis { + GizmoEmphasis::Regular => (COLOR_OVERLAY_WHITE, 1.), + GizmoEmphasis::Hovered => (COLOR_OVERLAY_WHITE, 2.), + GizmoEmphasis::Active => (COLOR_OVERLAY_BLUE, 1.), + }; self.render_context.set_fill_style_str(fill); self.render_context.set_stroke_style_str(COLOR_OVERLAY_BLUE); self.render_context.fill(); + self.render_context.set_line_width(stroke_width); self.render_context.stroke(); + self.render_context.set_line_width(1.); self.end_dpi_aware_transform(); } diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index 22d7a652..209295ab 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -3,7 +3,7 @@ use crate::consts::{ COLOR_OVERLAY_BLUE, DRAG_THRESHOLD, GRADIENT_MIDPOINT_DIAMOND_RADIUS, GRADIENT_MIDPOINT_MAX, GRADIENT_MIDPOINT_MIN, GRADIENT_STOP_MIN_VIEWPORT_GAP, LINE_ROTATE_SNAP_ANGLE, MANIPULATOR_GROUP_MARKER_SIZE, SEGMENT_INSERTION_DISTANCE, SEGMENT_OVERLAY_SIZE, SELECTION_THRESHOLD, }; -use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; +use crate::messages::portfolio::document::overlays::utility_types::{GizmoEmphasis, OverlayContext}; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; use crate::messages::tool::common_functionality::graph_modification_utils::{NodeGraphLayer, get_gradient}; @@ -35,8 +35,8 @@ pub enum GradientToolMessage { DoubleClick, InsertStop, PointerDown, - PointerMove { constrain_axis: Key }, - PointerOutsideViewport { constrain_axis: Key }, + PointerMove { constrain_axis: Key, lock_angle: Key }, + PointerOutsideViewport { constrain_axis: Key, lock_angle: Key }, PointerUp, UpdateOptions { options: GradientOptionsUpdate }, } @@ -148,13 +148,16 @@ impl LayoutHolder for GradientTool { #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum GradientToolFsmState { - Ready { hover_insertion: bool }, - Drawing, + Ready { hovering: GradientHoverTarget, selected: GradientSelectedTarget }, + Drawing { drag_hint: GradientDragHintState }, } impl Default for GradientToolFsmState { fn default() -> Self { - Self::Ready { hover_insertion: false } + Self::Ready { + hovering: GradientHoverTarget::None, + selected: GradientSelectedTarget::None, + } } } @@ -249,10 +252,12 @@ impl SelectedGradient { mut mouse: DVec2, responses: &mut VecDeque, snap_rotate: bool, + lock_angle: bool, gradient_type: GradientType, drag_start: DVec2, snap_data: SnapData, snap_manager: &mut SnapManager, + gradient_angle: &mut f64, ) { if mouse.distance(drag_start) < DRAG_THRESHOLD { self.gradient = self.initial_gradient.clone(); @@ -262,7 +267,7 @@ impl SelectedGradient { self.gradient.gradient_type = gradient_type; - if snap_rotate && matches!(self.dragging, GradientDragTarget::End | GradientDragTarget::Start | GradientDragTarget::New) { + if (lock_angle || snap_rotate) && matches!(self.dragging, GradientDragTarget::End | GradientDragTarget::Start | GradientDragTarget::New) { let point = if self.dragging == GradientDragTarget::Start { self.transform.transform_point2(self.gradient.end) } else if self.dragging == GradientDragTarget::New { @@ -273,15 +278,41 @@ impl SelectedGradient { let delta = point - mouse; - let length = delta.length(); let mut angle = -delta.angle_to(DVec2::X); - let snap_resolution = LINE_ROTATE_SNAP_ANGLE.to_radians(); - angle = (angle / snap_resolution).round() * snap_resolution; + if lock_angle { + angle = *gradient_angle; + } else if snap_rotate { + let snap_resolution = LINE_ROTATE_SNAP_ANGLE.to_radians(); + angle = (angle / snap_resolution).round() * snap_resolution; + } - let rotated = DVec2::new(length * angle.cos(), length * angle.sin()); - mouse = point - rotated; + *gradient_angle = angle; + + if lock_angle { + let unit_direction = DVec2::new(angle.cos(), angle.sin()); + let length = delta.dot(unit_direction); + mouse = point - length * unit_direction; + } else { + let length = delta.length(); + let rotated = DVec2::new(length * angle.cos(), length * angle.sin()); + mouse = point - rotated; + } } else { + // Update stored angle even when not constraining (for dragging endpoints and drawing a new gradient) + if matches!(self.dragging, GradientDragTarget::End | GradientDragTarget::Start | GradientDragTarget::New) { + let point = if self.dragging == GradientDragTarget::Start { + self.transform.transform_point2(self.gradient.end) + } else if self.dragging == GradientDragTarget::New { + drag_start + } else { + self.transform.transform_point2(self.gradient.start) + }; + + let delta = point - mouse; + *gradient_angle = -delta.angle_to(DVec2::X); + } + // Basic point snapping when not angle-constraining let document_to_viewport = snap_data.document.metadata().document_to_viewport; let document_mouse = document_to_viewport.inverse().transform_point2(mouse); @@ -348,22 +379,16 @@ impl SelectedGradient { return; } - // Clamp within neighboring stops with a minimum viewport-space gap away from the neighboring stops on either end + // Allow dragging through other stops (they'll reorder via sort), but clamp near + // the endpoints at 0 and 1 if a different color stop already occupies that position let min_gap = GRADIENT_STOP_MIN_VIEWPORT_GAP / line_length; - let initial_pos = self.initial_gradient.stops.position[s]; + let last_index = self.gradient.stops.len() - 1; - let left_bound = if s > 0 { - let left_neighbor = self.gradient.stops.position[s - 1]; - (left_neighbor + min_gap).min(initial_pos) - } else { - 0. - }; - let right_bound = if s + 1 < self.gradient.stops.len() { - let right_neighbor = self.gradient.stops.position[s + 1]; - (right_neighbor - min_gap).max(initial_pos) - } else { - 1. - }; + let has_other_stop_at_zero = s != 0 && self.gradient.stops.position.first().is_some_and(|&p| p.abs() < f64::EPSILON * 1000.); + let has_other_stop_at_one = s != last_index && self.gradient.stops.position.last().is_some_and(|&p| (1. - p).abs() < f64::EPSILON * 1000.); + + let left_bound = if has_other_stop_at_zero { min_gap } else { 0. }; + let right_bound = if has_other_stop_at_one { 1. - min_gap } else { 1. }; let clamped = new_pos.clamp(left_bound, right_bound); self.gradient.stops.position[s] = clamped; @@ -465,6 +490,8 @@ struct GradientToolData { snap_manager: SnapManager, drag_start: DVec2, auto_panning: AutoPanning, + auto_pan_shift: DVec2, + gradient_angle: f64, } impl Fsm for GradientToolFsmState { @@ -500,7 +527,7 @@ impl Fsm for GradientToolFsmState { .filter(|selected| selected.layer.is_some_and(|selected_layer| selected_layer == layer)) .map(|selected| selected.dragging); - let gradient = if self == GradientToolFsmState::Drawing + let gradient = if matches!(self, GradientToolFsmState::Drawing { .. }) && dragging.is_some() && let Some(selected_gradient) = selected.filter(|s| s.layer == Some(layer)) { @@ -523,23 +550,95 @@ impl Fsm for GradientToolFsmState { let first_at_start = stops.position.first().is_some_and(|&p| p.abs() < f64::EPSILON * 1000.); let last_at_end = stops.position.last().is_some_and(|&p| (1. - p).abs() < f64::EPSILON * 1000.); - let start_selected = first_at_start && (dragging == Some(GradientDragTarget::Start) || dragging == Some(GradientDragTarget::Stop(0))); - let end_selected = last_at_end && !stops.is_empty() && (dragging == Some(GradientDragTarget::End) || dragging == Some(GradientDragTarget::Stop(stops.len() - 1))); - overlay_context.line(start, end, None, None); - overlay_context.gradient_color_stop(start, start_selected, &start_hex, !first_at_start); - overlay_context.gradient_color_stop(end, end_selected, &end_hex, !last_at_end); + // Determine which stop is selected (being dragged) and hovered (closest to mouse) + // so they can be drawn last to appear on top of other overlapping stops + let selected_stop_id: Option = match dragging { + Some(GradientDragTarget::Start) => Some(StopId::Start), + Some(GradientDragTarget::End) => Some(StopId::End), + Some(GradientDragTarget::Stop(0)) if first_at_start => Some(StopId::Start), + Some(GradientDragTarget::Stop(i)) if last_at_end && i == stops.len() - 1 => Some(StopId::End), + Some(GradientDragTarget::Stop(i)) => Some(StopId::Middle(i)), + _ => None, + }; + let stop_tolerance = (MANIPULATOR_GROUP_MARKER_SIZE * 2.).powi(2); + let hovered_stop_id: Option = if !matches!(self, GradientToolFsmState::Drawing { .. }) { + // Find the closest stop to the mouse (matching the click detection logic) + let mut best: Option<(f64, StopId)> = None; + let mut check = |dist_sq: f64, id: StopId| { + if dist_sq < stop_tolerance && best.as_ref().is_none_or(|&(d, _)| dist_sq < d) { + best = Some((dist_sq, id)); + } + }; + check(start.distance_squared(mouse), StopId::Start); + check(end.distance_squared(mouse), StopId::End); + for (index, stop) in stops.iter().enumerate() { + if stop.position.abs() < f64::EPSILON * 1000. || (1. - stop.position).abs() < f64::EPSILON * 1000. { + continue; + } + check(start.lerp(end, stop.position).distance_squared(mouse), StopId::Middle(index)); + } + best.map(|(_, id)| id) + } else { + None + }; + + // Draw order: regular stops first, then selected, then hovered (so hovered appears on top) + let is_deferred = |id: StopId| -> bool { Some(id) == selected_stop_id || Some(id) == hovered_stop_id }; + let emphasis_for = |id: StopId| -> GizmoEmphasis { + if Some(id) == selected_stop_id { + GizmoEmphasis::Active + } else if Some(id) == hovered_stop_id { + GizmoEmphasis::Hovered + } else { + GizmoEmphasis::Regular + } + }; + let mut draw_stop = |id: StopId, emphasis: GizmoEmphasis| match id { + StopId::Start => overlay_context.gradient_color_stop(start, emphasis, &start_hex, !first_at_start), + StopId::End => overlay_context.gradient_color_stop(end, emphasis, &end_hex, !last_at_end), + StopId::Middle(i) => { + if let Some(stop) = stops.iter().nth(i) { + overlay_context.gradient_color_stop(start.lerp(end, stop.position), emphasis, &color_to_hex(stop.color), false); + } + } + }; + + // Draw regular (non-deferred) stops + if !is_deferred(StopId::Start) { + draw_stop(StopId::Start, emphasis_for(StopId::Start)); + } + if !is_deferred(StopId::End) { + draw_stop(StopId::End, emphasis_for(StopId::End)); + } for (index, stop) in stops.iter().enumerate() { if stop.position.abs() < f64::EPSILON * 1000. || (1. - stop.position).abs() < f64::EPSILON * 1000. { continue; } - overlay_context.gradient_color_stop(start.lerp(end, stop.position), dragging == Some(GradientDragTarget::Stop(index)), &color_to_hex(stop.color), false); + let id = StopId::Middle(index); + if !is_deferred(id) { + draw_stop(id, emphasis_for(id)); + } + } + + // Draw selected stop (if not also hovered) + if let Some(selected_id) = selected_stop_id + && Some(selected_id) != hovered_stop_id + { + draw_stop(selected_id, GizmoEmphasis::Active); + } + + // Draw hovered stop last (on top of everything) + if let Some(hov_id) = hovered_stop_id { + let emphasis = if Some(hov_id) == selected_stop_id { GizmoEmphasis::Active } else { GizmoEmphasis::Hovered }; + draw_stop(hov_id, emphasis); } // Draw midpoint diamonds between adjacent stops (hidden when stops are too close in viewport space) let line_angle = (end - start).to_angle(); let line_length = start.distance(end); + let midpoint_tolerance = GRADIENT_MIDPOINT_DIAMOND_RADIUS.powi(2); for i in 0..stops.position.len().saturating_sub(1) { let left = stops.position[i]; let right = stops.position[i + 1]; @@ -551,10 +650,17 @@ impl Fsm for GradientToolFsmState { let midpoint_pos = left + stops.midpoint[i] * (right - left); let midpoint_viewport = start.lerp(end, midpoint_pos); - overlay_context.gradient_midpoint(midpoint_viewport, dragging == Some(GradientDragTarget::Midpoint(i)), line_angle); + let emphasis = if dragging == Some(GradientDragTarget::Midpoint(i)) { + GizmoEmphasis::Active + } else if !matches!(self, GradientToolFsmState::Drawing { .. }) && midpoint_viewport.distance_squared(mouse) < midpoint_tolerance { + GizmoEmphasis::Hovered + } else { + GizmoEmphasis::Regular + }; + overlay_context.gradient_midpoint(midpoint_viewport, emphasis, line_angle); } - if self != GradientToolFsmState::Drawing + if !matches!(self, GradientToolFsmState::Drawing { .. }) && calculate_insertion(start, end, stops, mouse).is_some() && let Some(dir) = (end - start).try_normalize() { @@ -598,11 +704,15 @@ impl Fsm for GradientToolFsmState { } (GradientToolFsmState::Ready { .. }, GradientToolMessage::SelectionChanged) => { tool_data.selected_gradient = None; - self + GradientToolFsmState::Ready { + hovering: GradientHoverTarget::None, + selected: GradientSelectedTarget::None, + } } (_, GradientToolMessage::DoubleClick) => { // Only reset if the mouse hasn't moved so we don't trigger from a click-then-click-and-drag being reported as a double-click - if input.mouse.position.distance(tool_data.drag_start) <= DRAG_THRESHOLD + let drag_start_viewport = document.metadata().document_to_viewport.transform_point2(tool_data.drag_start); + if input.mouse.position.distance(drag_start_viewport) <= DRAG_THRESHOLD && let Some(selected_gradient) = &mut tool_data.selected_gradient && let GradientDragTarget::Midpoint(index) = selected_gradient.dragging { @@ -613,17 +723,22 @@ impl Fsm for GradientToolFsmState { self } (state, GradientToolMessage::DeleteStop) => { + let ready_default = GradientToolFsmState::Ready { + hovering: GradientHoverTarget::None, + selected: GradientSelectedTarget::None, + }; + let Some(selected_gradient) = &mut tool_data.selected_gradient else { - return GradientToolFsmState::Ready { hover_insertion: false }; + return ready_default; }; // Skip if invalid gradient if selected_gradient.gradient.stops.len() < 2 { - return GradientToolFsmState::Ready { hover_insertion: false }; + return ready_default; } // If we're in the middle of a drag, abort it first and revert to the initial gradient - if state == GradientToolFsmState::Drawing { + if matches!(state, GradientToolFsmState::Drawing { .. }) { selected_gradient.gradient = selected_gradient.initial_gradient.clone(); selected_gradient.render_gradient(responses); responses.add(DocumentMessage::AbortTransaction); @@ -640,7 +755,7 @@ impl Fsm for GradientToolFsmState { selected_gradient.gradient.stops.remove(0); } else { responses.add(DocumentMessage::AbortTransaction); - return GradientToolFsmState::Ready { hover_insertion: false }; + return ready_default; } } GradientDragTarget::End => { @@ -649,12 +764,12 @@ impl Fsm for GradientToolFsmState { let _ = selected_gradient.gradient.stops.pop(); } else { responses.add(DocumentMessage::AbortTransaction); - return GradientToolFsmState::Ready { hover_insertion: false }; + return ready_default; } } GradientDragTarget::New => { responses.add(DocumentMessage::AbortTransaction); - return GradientToolFsmState::Ready { hover_insertion: false }; + return ready_default; } GradientDragTarget::Stop(index) => { selected_gradient.gradient.stops.remove(index); @@ -666,7 +781,7 @@ impl Fsm for GradientToolFsmState { responses.add(DocumentMessage::CommitTransaction); responses.add(PropertiesPanelMessage::Refresh); - return GradientToolFsmState::Ready { hover_insertion: false }; + return ready_default; } }; @@ -680,7 +795,7 @@ impl Fsm for GradientToolFsmState { } responses.add(DocumentMessage::CommitTransaction); responses.add(PropertiesPanelMessage::Refresh); - return GradientToolFsmState::Ready { hover_insertion: false }; + return ready_default; } // Find the minimum and maximum positions @@ -688,6 +803,9 @@ impl Fsm for GradientToolFsmState { let max_position = selected_gradient.gradient.stops.position.iter().copied().reduce(f64::max).expect("No max"); // Recompute the start and end position of the gradient (in viewport transform) + if let Some(layer) = selected_gradient.layer { + selected_gradient.transform = gradient_space_transform(layer, document); + } let transform = selected_gradient.transform; let (start, end) = (transform.transform_point2(selected_gradient.gradient.start), transform.transform_point2(selected_gradient.gradient.end)); let (new_start, new_end) = (start.lerp(end, min_position), start.lerp(end, max_position)); @@ -705,7 +823,7 @@ impl Fsm for GradientToolFsmState { responses.add(PropertiesPanelMessage::Refresh); tool_data.selected_gradient = None; - GradientToolFsmState::Ready { hover_insertion: false } + ready_default } (_, GradientToolMessage::InsertStop) => { for layer in document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface) { @@ -754,17 +872,18 @@ impl Fsm for GradientToolFsmState { mouse = document_to_viewport.transform_point2(snapped.snapped_point_document); } - tool_data.drag_start = mouse; + tool_data.drag_start = document_to_viewport.inverse().transform_point2(mouse); + tool_data.auto_pan_shift = DVec2::ZERO; let tolerance = (MANIPULATOR_GROUP_MARKER_SIZE * 2.).powi(2); - let mut dragging = false; + let mut drag_hint: Option = None; let mut transaction_started = false; for layer in document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface) { let Some(gradient) = get_gradient(layer, &document.network_interface) else { continue }; let transform = gradient_space_transform(layer, document); // Check for dragging a midpoint diamond - if !dragging { + if drag_hint.is_none() { let (start, end) = (transform.transform_point2(gradient.start), transform.transform_point2(gradient.end)); let line_length = start.distance(end); let midpoint_tolerance = GRADIENT_MIDPOINT_DIAMOND_RADIUS.powi(2); @@ -780,7 +899,8 @@ impl Fsm for GradientToolFsmState { let midpoint_viewport = start.lerp(end, midpoint_pos); if midpoint_viewport.distance_squared(mouse) < midpoint_tolerance { - dragging = true; + let resettable = midpoint_is_resettable(gradient.stops.midpoint[i]); + drag_hint = Some(GradientDragHintState::Midpoint { resettable }); tool_data.selected_gradient = Some(SelectedGradient { layer: Some(layer), @@ -795,40 +915,49 @@ impl Fsm for GradientToolFsmState { } } - // Check for dragging step - if !dragging { + // Check for dragging the closest stop to the mouse pointer + if drag_hint.is_none() { + let mut best: Option<(f64, usize)> = None; for (index, stop) in gradient.stops.iter().enumerate() { let pos = transform.transform_point2(gradient.start.lerp(gradient.end, stop.position)); - if pos.distance_squared(mouse) < tolerance { - dragging = true; - - // Stops at position 0 or 1 are locked endpoints: dragging moves the - // gradient line endpoint geometry (start/end) instead of stop position - let drag_target = if stop.position.abs() < f64::EPSILON * 1000. { - GradientDragTarget::Start - } else if (1. - stop.position).abs() < f64::EPSILON * 1000. { - GradientDragTarget::End - } else { - GradientDragTarget::Stop(index) - }; - - tool_data.selected_gradient = Some(SelectedGradient { - layer: Some(layer), - transform, - gradient: gradient.clone(), - dragging: drag_target, - initial_gradient: gradient.clone(), - }) + let dist_sq = pos.distance_squared(mouse); + if dist_sq < tolerance && best.as_ref().is_none_or(|&(best_dist, _)| dist_sq < best_dist) { + best = Some((dist_sq, index)); } } + if let Some((_, index)) = best { + let stop_position = gradient.stops.position[index]; + // Stops at position 0 or 1 are locked endpoints: dragging moves the + // gradient line endpoint geometry (start/end) instead of stop position + let drag_target = if stop_position.abs() < f64::EPSILON * 1000. { + GradientDragTarget::Start + } else if (1. - stop_position).abs() < f64::EPSILON * 1000. { + GradientDragTarget::End + } else { + GradientDragTarget::Stop(index) + }; + + drag_hint = Some(match drag_target { + GradientDragTarget::Start | GradientDragTarget::End => GradientDragHintState::EndStop, + _ => GradientDragHintState::Stop, + }); + + tool_data.selected_gradient = Some(SelectedGradient { + layer: Some(layer), + transform, + gradient: gradient.clone(), + dragging: drag_target, + initial_gradient: gradient.clone(), + }); + } } // Check dragging start or end handle - if !dragging { + if drag_hint.is_none() { for (pos, dragging_target) in [(gradient.start, GradientDragTarget::Start), (gradient.end, GradientDragTarget::End)] { let pos = transform.transform_point2(pos); if pos.distance_squared(mouse) < tolerance { - dragging = true; + drag_hint = Some(GradientDragHintState::Endpoint); tool_data.selected_gradient = Some(SelectedGradient { layer: Some(layer), transform, @@ -841,7 +970,7 @@ impl Fsm for GradientToolFsmState { } // Insert stop if clicking on line - if !dragging { + if drag_hint.is_none() { let (start, end) = (transform.transform_point2(gradient.start), transform.transform_point2(gradient.end)); let distance = (end - start).angle_to(mouse - start).sin() * (mouse - start).length(); let projection = ((end - start).angle_to(mouse - start)).cos() * start.distance(mouse) / start.distance(end); @@ -857,14 +986,27 @@ impl Fsm for GradientToolFsmState { // No offset when inserting a new stop, it should be exactly under the mouse selected_gradient.render_gradient(responses); tool_data.selected_gradient = Some(selected_gradient); - dragging = true; + drag_hint = Some(GradientDragHintState::Stop); } } } } - let gradient_state = if dragging { - GradientToolFsmState::Drawing + // Initialize `gradient_angle` from the existing gradient so Ctrl (lock angle) works from the first mouse move + if let Some(sg) = &tool_data.selected_gradient { + let (vp_start, vp_end) = (sg.transform.transform_point2(sg.gradient.start), sg.transform.transform_point2(sg.gradient.end)); + let delta = match sg.dragging { + // When dragging End, the fixed point is start and the mouse begins at end + GradientDragTarget::End => vp_start - vp_end, + // When dragging Start, the fixed point is end and the mouse begins at start + GradientDragTarget::Start => vp_end - vp_start, + _ => vp_start - vp_end, + }; + tool_data.gradient_angle = -delta.angle_to(DVec2::X); + } + + let gradient_state = if let Some(hint) = drag_hint { + GradientToolFsmState::Drawing { drag_hint: hint } } else { let document_mouse = document.metadata().document_to_viewport.inverse().transform_point2(mouse); let selected_layer = document.click_based_on_position(document_mouse); @@ -873,7 +1015,10 @@ impl Fsm for GradientToolFsmState { if let Some(layer) = selected_layer { // Add check for raster layer if NodeGraphLayer::is_raster_layer(layer, &mut document.network_interface) { - return GradientToolFsmState::Ready { hover_insertion: false }; + return GradientToolFsmState::Ready { + hovering: GradientHoverTarget::None, + selected: GradientSelectedTarget::None, + }; } if !document.network_interface.selected_nodes().selected_layers_contains(layer, document.metadata()) { let nodes = vec![layer.to_node()]; @@ -893,13 +1038,18 @@ impl Fsm for GradientToolFsmState { tool_data.selected_gradient = Some(selected_gradient); - GradientToolFsmState::Drawing + GradientToolFsmState::Drawing { + drag_hint: GradientDragHintState::NewGradient, + } } else { - GradientToolFsmState::Ready { hover_insertion: false } + GradientToolFsmState::Ready { + hovering: GradientHoverTarget::None, + selected: GradientSelectedTarget::None, + } } }; - if gradient_state == GradientToolFsmState::Drawing && !transaction_started { + if matches!(gradient_state, GradientToolFsmState::Drawing { .. }) && !transaction_started { responses.add(DocumentMessage::StartTransaction); } @@ -907,54 +1057,65 @@ impl Fsm for GradientToolFsmState { gradient_state } - (GradientToolFsmState::Drawing, GradientToolMessage::PointerMove { constrain_axis }) => { + (GradientToolFsmState::Drawing { drag_hint }, GradientToolMessage::PointerMove { constrain_axis, lock_angle }) => { if let Some(selected_gradient) = &mut tool_data.selected_gradient { let mouse = input.mouse.position; let snap_data = SnapData::new(document, input, viewport); + // Recompute the gradient-to-viewport transform fresh each frame so zoom/pan mid-drag works correctly + if let Some(layer) = selected_gradient.layer { + selected_gradient.transform = gradient_space_transform(layer, document); + selected_gradient.transform.translation += tool_data.auto_pan_shift; + } + + // Convert drag_start from document space to effective viewport space + let d2v = document.metadata().document_to_viewport; + let drag_start_viewport = d2v.transform_point2(tool_data.drag_start) + tool_data.auto_pan_shift; + tool_data.auto_pan_shift = DVec2::ZERO; + selected_gradient.update_gradient( mouse, responses, input.keyboard.get(constrain_axis as usize), + input.keyboard.get(lock_angle as usize), selected_gradient.gradient.gradient_type, - tool_data.drag_start, + drag_start_viewport, snap_data, &mut tool_data.snap_manager, + &mut tool_data.gradient_angle, ); } // Auto-panning let messages = [ - GradientToolMessage::PointerOutsideViewport { constrain_axis }.into(), - GradientToolMessage::PointerMove { constrain_axis }.into(), + GradientToolMessage::PointerOutsideViewport { constrain_axis, lock_angle }.into(), + GradientToolMessage::PointerMove { constrain_axis, lock_angle }.into(), ]; tool_data.auto_panning.setup_by_mouse_position(input, viewport, &messages, responses); responses.add(OverlaysMessage::Draw); - GradientToolFsmState::Drawing + GradientToolFsmState::Drawing { drag_hint } } - (GradientToolFsmState::Drawing, GradientToolMessage::PointerOutsideViewport { .. }) => { + (GradientToolFsmState::Drawing { drag_hint }, GradientToolMessage::PointerOutsideViewport { .. }) => { // Auto-panning - if let Some(shift) = tool_data.auto_panning.shift_viewport(input, viewport, responses) - && let Some(selected_gradient) = &mut tool_data.selected_gradient - { - selected_gradient.transform.translation += shift; + if let Some(shift) = tool_data.auto_panning.shift_viewport(input, viewport, responses) { + tool_data.auto_pan_shift += shift; } - GradientToolFsmState::Drawing + GradientToolFsmState::Drawing { drag_hint } } - (state, GradientToolMessage::PointerOutsideViewport { constrain_axis }) => { + (state, GradientToolMessage::PointerOutsideViewport { constrain_axis, lock_angle }) => { // Auto-panning let messages = [ - GradientToolMessage::PointerOutsideViewport { constrain_axis }.into(), - GradientToolMessage::PointerMove { constrain_axis }.into(), + GradientToolMessage::PointerOutsideViewport { constrain_axis, lock_angle }.into(), + GradientToolMessage::PointerMove { constrain_axis, lock_angle }.into(), ]; tool_data.auto_panning.stop(&messages, responses); state } - (GradientToolFsmState::Drawing, GradientToolMessage::PointerUp) => { + (GradientToolFsmState::Drawing { .. }, GradientToolMessage::PointerUp) => { responses.add(DocumentMessage::EndTransaction); tool_data.snap_manager.cleanup(responses); @@ -967,58 +1128,115 @@ impl Fsm for GradientToolFsmState { tool_data.selected_gradient = None; } - GradientToolFsmState::Ready { hover_insertion: false } + let selected = compute_selected_target(tool_data); + GradientToolFsmState::Ready { + hovering: GradientHoverTarget::None, + selected, + } } (GradientToolFsmState::Ready { .. }, GradientToolMessage::PointerMove { .. }) => { - let mut hover_insertion = false; let mouse = input.mouse.position; - - for layer in document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface) { - let Some(gradient) = get_gradient(layer, &document.network_interface) else { continue }; - let transform = gradient_space_transform(layer, document); - let start = transform.transform_point2(gradient.start); - let end = transform.transform_point2(gradient.end); - - if calculate_insertion(start, end, &gradient.stops, mouse).is_some() { - hover_insertion = true; - break; - } - } + let hovering = detect_hover_target(mouse, document); + let selected = compute_selected_target(tool_data); let snap_data = SnapData::new(document, input, viewport); tool_data.snap_manager.preview_draw_gradient(&snap_data, mouse); responses.add(OverlaysMessage::Draw); - GradientToolFsmState::Ready { hover_insertion } + GradientToolFsmState::Ready { hovering, selected } } - (GradientToolFsmState::Drawing, GradientToolMessage::Abort) => { + (GradientToolFsmState::Drawing { .. }, GradientToolMessage::Abort) => { responses.add(DocumentMessage::AbortTransaction); tool_data.snap_manager.cleanup(responses); tool_data.selected_gradient = None; responses.add(OverlaysMessage::Draw); - GradientToolFsmState::Ready { hover_insertion: false } + GradientToolFsmState::Ready { + hovering: GradientHoverTarget::None, + selected: GradientSelectedTarget::None, + } } - (_, GradientToolMessage::Abort) => GradientToolFsmState::Ready { hover_insertion: false }, + (_, GradientToolMessage::Abort) => GradientToolFsmState::Ready { + hovering: GradientHoverTarget::None, + selected: GradientSelectedTarget::None, + }, _ => self, } } fn update_hints(&self, responses: &mut VecDeque) { let hint_data = match self { - GradientToolFsmState::Ready { hover_insertion } => { - let hints = if *hover_insertion { - vec![HintInfo::mouse(MouseMotion::Lmb, "Insert Color Stop")] - } else { - vec![HintInfo::mouse(MouseMotion::LmbDrag, "Draw Gradient"), HintInfo::keys([Key::Shift], "15° Increments").prepend_plus()] - }; - HintData(vec![HintGroup(hints)]) + GradientToolFsmState::Ready { hovering, selected } => { + let mut groups = Vec::new(); + + // Primary hints based on hover target + match hovering { + GradientHoverTarget::None => { + groups.push(HintGroup(vec![ + HintInfo::mouse(MouseMotion::LmbDrag, "Draw Gradient"), + HintInfo::keys([Key::Shift], "15° Increments").prepend_plus(), + HintInfo::keys([Key::Control], "Lock Angle").prepend_plus(), + ])); + } + GradientHoverTarget::InsertionPoint => { + groups.push(HintGroup(vec![HintInfo::mouse(MouseMotion::Lmb, "Insert Color Stop")])); + } + GradientHoverTarget::Stop => { + groups.push(HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDrag, "Move Color Stop")])); + } + GradientHoverTarget::Endpoint => { + groups.push(HintGroup(vec![ + HintInfo::mouse(MouseMotion::LmbDrag, "Move Gradient End"), + HintInfo::keys([Key::Shift], "15° Increments").prepend_plus(), + HintInfo::keys([Key::Control], "Lock Angle").prepend_plus(), + ])); + } + GradientHoverTarget::Midpoint { resettable } => { + groups.push(HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDrag, "Move Midpoint")])); + if *resettable { + groups.push(HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDouble, "Reset Midpoint")])); + } + } + } + + // Delete/reset hint based on selection + match selected { + GradientSelectedTarget::Stop => { + groups.push(HintGroup(vec![HintInfo::keys([Key::Backspace], "Delete Color Stop")])); + } + GradientSelectedTarget::Midpoint { resettable: true } => { + groups.push(HintGroup(vec![HintInfo::keys([Key::Backspace], "Reset Midpoint")])); + } + _ => {} + } + + HintData(groups) + } + GradientToolFsmState::Drawing { drag_hint } => { + let mut groups = Vec::new(); + + // Abort hints + groups.push(HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()])); + + // Angle constraint hint (only for endpoint/end color stop/new gradient dragging) + if matches!(drag_hint, GradientDragHintState::NewGradient | GradientDragHintState::Endpoint | GradientDragHintState::EndStop) { + groups.push(HintGroup(vec![HintInfo::keys([Key::Shift], "15° Increments"), HintInfo::keys([Key::Control], "Lock Angle")])); + } + + // Delete/reset hint while dragging + match drag_hint { + GradientDragHintState::EndStop | GradientDragHintState::Stop => { + groups.push(HintGroup(vec![HintInfo::keys([Key::Backspace], "Delete Color Stop")])); + } + GradientDragHintState::Midpoint { resettable: true } => { + groups.push(HintGroup(vec![HintInfo::keys([Key::Backspace], "Reset Midpoint")])); + } + _ => {} + } + + HintData(groups) } - GradientToolFsmState::Drawing => HintData(vec![ - HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()]), - HintGroup(vec![HintInfo::keys([Key::Shift], "15° Increments")]), - ]), }; hint_data.send_layout(responses); @@ -1029,6 +1247,123 @@ impl Fsm for GradientToolFsmState { } } +fn detect_hover_target(mouse: DVec2, document: &DocumentMessageHandler) -> GradientHoverTarget { + let stop_tolerance = (MANIPULATOR_GROUP_MARKER_SIZE * 2.).powi(2); + let midpoint_tolerance = GRADIENT_MIDPOINT_DIAMOND_RADIUS.powi(2); + + for layer in document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface) { + let Some(gradient) = get_gradient(layer, &document.network_interface) else { continue }; + let transform = gradient_space_transform(layer, document); + let (start, end) = (transform.transform_point2(gradient.start), transform.transform_point2(gradient.end)); + let line_length = start.distance(end); + + // Check midpoint diamonds first (smaller hit area, higher priority) + for i in 0..gradient.stops.position.len().saturating_sub(1) { + let left = gradient.stops.position[i]; + let right = gradient.stops.position[i + 1]; + if midpoint_hidden_by_proximity(left, right, line_length) { + continue; + } + + let midpoint_position = left + gradient.stops.midpoint[i] * (right - left); + let midpoint_viewport = start.lerp(end, midpoint_position); + + if midpoint_viewport.distance_squared(mouse) < midpoint_tolerance { + let resettable = midpoint_is_resettable(gradient.stops.midpoint[i]); + return GradientHoverTarget::Midpoint { resettable }; + } + } + + // Check stops + for stop in gradient.stops.iter() { + let pos = transform.transform_point2(gradient.start.lerp(gradient.end, stop.position)); + if pos.distance_squared(mouse) < stop_tolerance { + return if stop.position.abs() < f64::EPSILON * 1000. || (1. - stop.position).abs() < f64::EPSILON * 1000. { + GradientHoverTarget::Endpoint + } else { + GradientHoverTarget::Stop + }; + } + } + + // Check start/end handles (pure endpoints without stops) + for endpoint_position in [gradient.start, gradient.end] { + let endpoint_position = transform.transform_point2(endpoint_position); + if endpoint_position.distance_squared(mouse) < stop_tolerance { + return GradientHoverTarget::Endpoint; + } + } + + // Check insertion point on line + if calculate_insertion(start, end, &gradient.stops, mouse).is_some() { + return GradientHoverTarget::InsertionPoint; + } + } + + GradientHoverTarget::None +} + +fn compute_selected_target(tool_data: &GradientToolData) -> GradientSelectedTarget { + let Some(selected_gradient) = &tool_data.selected_gradient else { + return GradientSelectedTarget::None; + }; + + match selected_gradient.dragging { + GradientDragTarget::Stop(_) | GradientDragTarget::Start | GradientDragTarget::End => GradientSelectedTarget::Stop, + GradientDragTarget::Midpoint(i) => { + let resettable = selected_gradient.gradient.stops.midpoint.get(i).is_some_and(|&midpoint_value| midpoint_is_resettable(midpoint_value)); + GradientSelectedTarget::Midpoint { resettable } + } + GradientDragTarget::New => GradientSelectedTarget::None, + } +} + +#[inline(always)] +fn midpoint_is_resettable(value: f64) -> bool { + (value - 0.5).abs() >= f64::EPSILON * 1000. +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum StopId { + Start, + End, + Middle(usize), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +enum GradientHoverTarget { + #[default] + None, + InsertionPoint, + Stop, + Endpoint, + Midpoint { + resettable: bool, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +enum GradientSelectedTarget { + #[default] + None, + Stop, + Midpoint { + resettable: bool, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +enum GradientDragHintState { + #[default] + NewGradient, + Endpoint, + EndStop, + Stop, + Midpoint { + resettable: bool, + }, +} + #[cfg(test)] mod test_gradient { use crate::messages::input_mapper::utility_types::input_mouse::EditorMouseState; diff --git a/editor/src/messages/tool/tool_messages/path_tool.rs b/editor/src/messages/tool/tool_messages/path_tool.rs index 99d08acb..e264348f 100644 --- a/editor/src/messages/tool/tool_messages/path_tool.rs +++ b/editor/src/messages/tool/tool_messages/path_tool.rs @@ -3477,7 +3477,7 @@ fn update_dynamic_hints( } let drag_selected_hints = vec![HintInfo::mouse(MouseMotion::LmbDrag, "Drag Selected")]; - let mut delete_selected_hints = vec![HintInfo::keys([Key::Delete], "Delete Selected")]; + let mut delete_selected_hints = vec![HintInfo::keys([Key::Backspace], "Delete Selected")]; if at_least_one_anchor_selected { delete_selected_hints.push(HintInfo::keys([Key::Accel], "No Dissolve").prepend_plus());