From 4a6cdffd8460a007882f62eef118587ee11e2efb Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 24 Feb 2026 20:25:15 -0800 Subject: [PATCH] Add draggable diamond midpoint gizmos to the Gradient tool (#3826) --- editor/src/consts.rs | 6 + .../messages/input_mapper/input_mappings.rs | 1 + .../document/overlays/utility_types_native.rs | 39 +- .../document/overlays/utility_types_web.rs | 37 +- .../tool/tool_messages/gradient_tool.rs | 386 +++++++++++++++--- .../libraries/vector-types/src/gradient.rs | 7 +- 6 files changed, 401 insertions(+), 75 deletions(-) diff --git a/editor/src/consts.rs b/editor/src/consts.rs index 5c820ff3..91bcd099 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -111,6 +111,12 @@ pub const SEGMENT_OVERLAY_SIZE: f64 = 10.; pub const SEGMENT_SELECTED_THICKNESS: f64 = 3.; pub const HANDLE_LENGTH_FACTOR: f64 = 0.5; +// GRADIENT TOOL +pub const GRADIENT_MIDPOINT_DIAMOND_RADIUS: f64 = 4.; +pub const GRADIENT_MIDPOINT_MIN: f64 = 0.01; +pub const GRADIENT_MIDPOINT_MAX: f64 = 0.99; +pub const GRADIENT_STOP_MIN_VIEWPORT_GAP: f64 = 10.; + // PEN TOOL pub const CREATE_CURVE_THRESHOLD: f64 = 5.; diff --git a/editor/src/messages/input_mapper/input_mappings.rs b/editor/src/messages/input_mapper/input_mappings.rs index 644b8d5a..ece19b1c 100644 --- a/editor/src/messages/input_mapper/input_mappings.rs +++ b/editor/src/messages/input_mapper/input_mappings.rs @@ -175,6 +175,7 @@ pub fn input_mappings(zoom_with_scroll: bool) -> Mapping { entry!(KeyDown(Enter); modifiers=[Accel], action_dispatch=TextToolMessage::Abort), // // 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!(KeyUp(MouseLeft); action_dispatch=GradientToolMessage::PointerUp), 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 2e5afb04..73b1f423 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs @@ -1,7 +1,7 @@ use crate::consts::{ ARC_SWEEP_GIZMO_RADIUS, COLOR_OVERLAY_BLACK, COLOR_OVERLAY_BLUE, COLOR_OVERLAY_BLUE_50, COLOR_OVERLAY_GREEN, COLOR_OVERLAY_RED, COLOR_OVERLAY_WHITE, COLOR_OVERLAY_YELLOW, COLOR_OVERLAY_YELLOW_DULL, COMPASS_ROSE_ARROW_SIZE, COMPASS_ROSE_HOVER_RING_DIAMETER, COMPASS_ROSE_MAIN_RING_DIAMETER, COMPASS_ROSE_RING_INNER_DIAMETER, DOWEL_PIN_RADIUS, - MANIPULATOR_GROUP_MARKER_SIZE, PIVOT_CROSSHAIR_LENGTH, PIVOT_CROSSHAIR_THICKNESS, PIVOT_DIAMETER, RESIZE_HANDLE_SIZE, SKEW_TRIANGLE_OFFSET, SKEW_TRIANGLE_SIZE, + GRADIENT_MIDPOINT_DIAMOND_RADIUS, MANIPULATOR_GROUP_MARKER_SIZE, PIVOT_CROSSHAIR_LENGTH, PIVOT_CROSSHAIR_THICKNESS, PIVOT_DIAMETER, RESIZE_HANDLE_SIZE, SKEW_TRIANGLE_OFFSET, SKEW_TRIANGLE_SIZE, }; use crate::messages::portfolio::document::overlays::utility_functions::{GLOBAL_FONT_CACHE, GLOBAL_TEXT_CONTEXT}; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; @@ -273,8 +273,12 @@ impl OverlayContext { self.internal().manipulator_anchor(position, selected, color); } - pub fn gradient_color_stop(&mut self, position: DVec2, selected: bool, color: &str) { - self.internal().gradient_color_stop(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_midpoint(&mut self, position: DVec2, selected: bool, angle: f64) { + self.internal().gradient_midpoint(position, selected, angle); } pub fn resize_handle(&mut self, position: DVec2, rotation: f64) { @@ -587,12 +591,12 @@ 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) { + fn gradient_color_stop(&mut self, position: DVec2, selected: bool, 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 = MANIPULATOR_GROUP_MARKER_SIZE / 1.5 + 1. + radius_offset; + 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| { let circle = kurbo::Circle::new((position.x, position.y), radius); @@ -610,6 +614,31 @@ impl OverlayContextInternal { draw_circle(radius, Some(stroke_width), COLOR_OVERLAY_WHITE); } + fn gradient_midpoint(&mut self, position: DVec2, selected: bool, angle: f64) { + let transform = self.get_transform(); + let position = position.round() - DVec2::splat(0.5); + + // Rotate diamond points by the gradient line angle + let (sin, cos) = angle.sin_cos(); + let rotate = |dx: f64, dy: f64| DVec2::new(dx * cos - dy * sin, dx * sin + dy * cos); + + let top = rotate(0., -GRADIENT_MIDPOINT_DIAMOND_RADIUS); + let right = rotate(GRADIENT_MIDPOINT_DIAMOND_RADIUS, 0.); + let bottom = rotate(0., GRADIENT_MIDPOINT_DIAMOND_RADIUS); + let left = rotate(-GRADIENT_MIDPOINT_DIAMOND_RADIUS, 0.); + + let mut path = BezPath::new(); + path.move_to(kurbo::Point::new(position.x + top.x, position.y + top.y)); + path.line_to(kurbo::Point::new(position.x + right.x, position.y + right.y)); + path.line_to(kurbo::Point::new(position.x + bottom.x, position.y + bottom.y)); + 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 }; + 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); + } + fn resize_handle(&mut self, position: DVec2, rotation: f64) { let quad = DAffine2::from_angle_translation(rotation, position) * Quad::from_box([DVec2::splat(-RESIZE_HANDLE_SIZE / 2.), DVec2::splat(RESIZE_HANDLE_SIZE / 2.)]); self.quad(quad, None, Some(COLOR_OVERLAY_WHITE)); 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 a80166f7..811104c7 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_web.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_web.rs @@ -2,7 +2,8 @@ use super::utility_functions::overlay_canvas_context; use crate::consts::{ ARC_SWEEP_GIZMO_RADIUS, COLOR_OVERLAY_BLACK, COLOR_OVERLAY_BLUE, COLOR_OVERLAY_BLUE_50, COLOR_OVERLAY_GREEN, COLOR_OVERLAY_RED, COLOR_OVERLAY_WHITE, COLOR_OVERLAY_YELLOW, COLOR_OVERLAY_YELLOW_DULL, COMPASS_ROSE_ARROW_SIZE, COMPASS_ROSE_HOVER_RING_DIAMETER, COMPASS_ROSE_MAIN_RING_DIAMETER, COMPASS_ROSE_RING_INNER_DIAMETER, DOWEL_PIN_RADIUS, - MANIPULATOR_GROUP_MARKER_SIZE, PIVOT_CROSSHAIR_LENGTH, PIVOT_CROSSHAIR_THICKNESS, PIVOT_DIAMETER, RESIZE_HANDLE_SIZE, SEGMENT_SELECTED_THICKNESS, SKEW_TRIANGLE_OFFSET, SKEW_TRIANGLE_SIZE, + GRADIENT_MIDPOINT_DIAMOND_RADIUS, MANIPULATOR_GROUP_MARKER_SIZE, PIVOT_CROSSHAIR_LENGTH, PIVOT_CROSSHAIR_THICKNESS, PIVOT_DIAMETER, RESIZE_HANDLE_SIZE, SEGMENT_SELECTED_THICKNESS, + SKEW_TRIANGLE_OFFSET, SKEW_TRIANGLE_SIZE, }; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::prelude::Message; @@ -468,13 +469,13 @@ 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) { + pub fn gradient_color_stop(&mut self, position: DVec2, selected: bool, 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 = MANIPULATOR_GROUP_MARKER_SIZE / 1.5 + 1. + radius_offset; + 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| { self.render_context.begin_path(); @@ -500,6 +501,36 @@ impl OverlayContext { self.end_dpi_aware_transform(); } + pub fn gradient_midpoint(&mut self, position: DVec2, selected: bool, angle: f64) { + self.start_dpi_aware_transform(); + + let position = position.round() - DVec2::splat(0.5); + + // Rotate diamond points by the gradient line angle + let (sin, cos) = angle.sin_cos(); + let rotate = |dx: f64, dy: f64| DVec2::new(dx * cos - dy * sin, dx * sin + dy * cos); + + let top = rotate(0., -GRADIENT_MIDPOINT_DIAMOND_RADIUS); + let right = rotate(GRADIENT_MIDPOINT_DIAMOND_RADIUS, 0.); + let bottom = rotate(0., GRADIENT_MIDPOINT_DIAMOND_RADIUS); + let left = rotate(-GRADIENT_MIDPOINT_DIAMOND_RADIUS, 0.); + + self.render_context.begin_path(); + self.render_context.move_to(position.x + top.x, position.y + top.y); + self.render_context.line_to(position.x + right.x, position.y + right.y); + self.render_context.line_to(position.x + bottom.x, position.y + bottom.y); + 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 }; + 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.stroke(); + + self.end_dpi_aware_transform(); + } + pub fn hover_manipulator_handle(&mut self, position: DVec2, selected: bool) { self.start_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 607caafc..22d7a652 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -1,5 +1,8 @@ use super::tool_prelude::*; -use crate::consts::{COLOR_OVERLAY_BLUE, DRAG_THRESHOLD, LINE_ROTATE_SNAP_ANGLE, MANIPULATOR_GROUP_MARKER_SIZE, SEGMENT_INSERTION_DISTANCE, SEGMENT_OVERLAY_SIZE, SELECTION_THRESHOLD}; +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::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; @@ -29,6 +32,7 @@ pub enum GradientToolMessage { // Tool-specific messages DeleteStop, + DoubleClick, InsertStop, PointerDown, PointerMove { constrain_axis: Key }, @@ -113,6 +117,7 @@ impl<'a> MessageHandler> for Grad PointerDown, PointerUp, PointerMove, + DoubleClick, Abort, DeleteStop, ); @@ -163,12 +168,18 @@ fn gradient_space_transform(layer: LayerNodeIdentifier, document: &DocumentMessa multiplied * bound_transform } +/// Whether two adjacent stops are too closely packed in viewport space for a midpoint diamond to be shown or interacted with. +fn midpoint_hidden_by_proximity(left_stop_pos: f64, right_stop_pos: f64, viewport_line_length: f64) -> bool { + (right_stop_pos - left_stop_pos) * viewport_line_length < GRADIENT_STOP_MIN_VIEWPORT_GAP * 2. +} + #[derive(PartialEq, Eq, Clone, Copy, Debug, Default)] pub enum GradientDragTarget { Start, #[default] End, - Step(usize), + Stop(usize), + Midpoint(usize), New, } @@ -197,6 +208,23 @@ fn calculate_insertion(start: DVec2, end: DVec2, stops: &GradientStops, mouse: D return None; } + // Don't insert when clicking near a (currently visible) midpoint diamond + let line_length = start.distance(end); + for i in 0..stops.position.len().saturating_sub(1) { + let left = stops.position[i]; + let right = stops.position[i + 1]; + + if midpoint_hidden_by_proximity(left, right, line_length) { + continue; + } + + let midpoint_pos = left + stops.midpoint[i] * (right - left); + let midpoint_viewport = start.lerp(end, midpoint_pos); + if midpoint_viewport.distance_squared(mouse) < GRADIENT_MIDPOINT_DIAMOND_RADIUS.powi(2) { + return None; + } + } + return Some(projection); } @@ -278,10 +306,17 @@ impl SelectedGradient { self.gradient.start = self.transform.inverse().transform_point2(drag_start); self.gradient.end = transformed_mouse; } - GradientDragTarget::Step(s) => { + GradientDragTarget::Stop(s) => { let document_to_viewport = snap_data.document.metadata().document_to_viewport; let (viewport_start, viewport_end) = (self.transform.transform_point2(self.gradient.start), self.transform.transform_point2(self.gradient.end)); + + let line_length = viewport_start.distance(viewport_end); + if line_length < f64::EPSILON { + self.render_gradient(responses); + return; + } + let (document_start, document_end) = ( document_to_viewport.inverse().transform_point2(viewport_start), document_to_viewport.inverse().transform_point2(viewport_end), @@ -306,16 +341,90 @@ impl SelectedGradient { snap_manager.update_indicator(snapped); // Calculate the new position by finding the closest point on the line - let new_pos = ((viewport_end - viewport_start).angle_to(projected_mouse - viewport_start)).cos() * viewport_start.distance(projected_mouse) / viewport_start.distance(viewport_end); + let new_pos = ((viewport_end - viewport_start).angle_to(projected_mouse - viewport_start)).cos() * viewport_start.distance(projected_mouse) / line_length; - // Should not go off end but can swap - let clamped = new_pos.clamp(0., 1.); + if !new_pos.is_finite() { + self.render_gradient(responses); + return; + } + + // Clamp within neighboring stops with a minimum viewport-space gap away from the neighboring stops on either end + let min_gap = GRADIENT_STOP_MIN_VIEWPORT_GAP / line_length; + let initial_pos = self.initial_gradient.stops.position[s]; + + 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 clamped = new_pos.clamp(left_bound, right_bound); self.gradient.stops.position[s] = clamped; let new_position = self.gradient.stops.position[s]; let new_color = self.gradient.stops.color[s]; self.gradient.stops.sort(); - self.dragging = GradientDragTarget::Step(self.gradient.stops.iter().position(|s| s.position == new_position && s.color == new_color).unwrap()); + if let Some(new_index) = self.gradient.stops.iter().position(|s| s.position == new_position && s.color == new_color) { + self.dragging = GradientDragTarget::Stop(new_index); + } + } + GradientDragTarget::Midpoint(midpoint_index) => { + let document_to_viewport = snap_data.document.metadata().document_to_viewport; + + let (viewport_start, viewport_end) = (self.transform.transform_point2(self.gradient.start), self.transform.transform_point2(self.gradient.end)); + + let line_length = viewport_start.distance(viewport_end); + if line_length < f64::EPSILON { + self.render_gradient(responses); + return; + } + + let (document_start, document_end) = ( + document_to_viewport.inverse().transform_point2(viewport_start), + document_to_viewport.inverse().transform_point2(viewport_end), + ); + + let constraint = SnapConstraint::Line { + origin: document_start, + direction: document_end - document_start, + }; + + let document_mouse = document_to_viewport.inverse().transform_point2(mouse); + let point_candidate = SnapCandidatePoint::gradient_handle(document_mouse); + + let snapped = snap_manager.constrained_snap(&snap_data, &point_candidate, constraint, SnapTypeConfiguration::default()); + + let projected_mouse_document = if snapped.is_snapped() { + snapped.snapped_point_document + } else { + constraint.projection(document_mouse) + }; + let projected_mouse = document_to_viewport.transform_point2(projected_mouse_document); + snap_manager.update_indicator(snapped); + + // Calculate the position along the full gradient (0-1) + let full_pos = ((viewport_end - viewport_start).angle_to(projected_mouse - viewport_start)).cos() * viewport_start.distance(projected_mouse) / line_length; + + if !full_pos.is_finite() { + self.render_gradient(responses); + return; + } + + // Convert to a midpoint ratio within the interval between the two surrounding stops + let left_stop = self.gradient.stops.position[midpoint_index]; + let right_stop = self.gradient.stops.position[midpoint_index + 1]; + let range = right_stop - left_stop; + if range > 0. { + let midpoint_ratio = ((full_pos - left_stop) / range).clamp(GRADIENT_MIDPOINT_MIN, GRADIENT_MIDPOINT_MAX); + self.gradient.stops.midpoint[midpoint_index] = midpoint_ratio; + } } } self.render_gradient(responses); @@ -391,7 +500,8 @@ impl Fsm for GradientToolFsmState { .filter(|selected| selected.layer.is_some_and(|selected_layer| selected_layer == layer)) .map(|selected| selected.dragging); - let gradient = if dragging.is_some() + let gradient = if self == GradientToolFsmState::Drawing + && dragging.is_some() && let Some(selected_gradient) = selected.filter(|s| s.layer == Some(layer)) { &selected_gradient.gradient @@ -409,21 +519,75 @@ impl Fsm for GradientToolFsmState { let start_hex = stops.color.first().map(|&c| color_to_hex(c)).unwrap_or(String::from(COLOR_OVERLAY_BLUE)); let end_hex = stops.color.last().map(|&c| color_to_hex(c)).unwrap_or(String::from(COLOR_OVERLAY_BLUE)); + // Check if the first/last stops are at position ~0/~1 (rendered as the endpoint dots rather than as separate stops) + 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, dragging == Some(GradientDragTarget::Start), &start_hex); - overlay_context.gradient_color_stop(end, dragging == Some(GradientDragTarget::End), &end_hex); + 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); 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::Step(index)), &color_to_hex(stop.color)); + overlay_context.gradient_color_stop(start.lerp(end, stop.position), dragging == Some(GradientDragTarget::Stop(index)), &color_to_hex(stop.color), false); } - if let (Some(projection), Some(dir)) = (calculate_insertion(start, end, stops, mouse), (end - start).try_normalize()) { + // 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); + for i in 0..stops.position.len().saturating_sub(1) { + let left = stops.position[i]; + let right = stops.position[i + 1]; + + if midpoint_hidden_by_proximity(left, right, line_length) { + continue; + } + + 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); + } + + if self != GradientToolFsmState::Drawing + && calculate_insertion(start, end, stops, mouse).is_some() + && let Some(dir) = (end - start).try_normalize() + { let perp = dir.perp(); - let point = start.lerp(end, projection); - overlay_context.line(point - perp * SEGMENT_OVERLAY_SIZE, point + perp * SEGMENT_OVERLAY_SIZE, Some(COLOR_OVERLAY_BLUE), Some(1.)); + + // Snap the insertion point along the gradient line + let document_to_viewport = document.metadata().document_to_viewport; + + let (document_start, document_end) = (document_to_viewport.inverse().transform_point2(start), document_to_viewport.inverse().transform_point2(end)); + let constraint = SnapConstraint::Line { + origin: document_start, + direction: document_end - document_start, + }; + + let document_mouse = document_to_viewport.inverse().transform_point2(mouse); + let point_candidate = SnapCandidatePoint::gradient_handle(document_mouse); + + let snap_data = SnapData::new(document, input, viewport); + let snapped = tool_data.snap_manager.constrained_snap(&snap_data, &point_candidate, constraint, SnapTypeConfiguration::default()); + + let snapped_point = if snapped.is_snapped() { + document_to_viewport.transform_point2(snapped.snapped_point_document) + } else { + let projected = constraint.projection(document_mouse); + document_to_viewport.transform_point2(projected) + }; + + overlay_context.line( + snapped_point - perp * SEGMENT_OVERLAY_SIZE, + snapped_point + perp * SEGMENT_OVERLAY_SIZE, + Some(COLOR_OVERLAY_BLUE), + Some(1.), + ); } } @@ -436,14 +600,34 @@ impl Fsm for GradientToolFsmState { tool_data.selected_gradient = None; self } - (GradientToolFsmState::Ready { .. }, GradientToolMessage::DeleteStop) => { + (_, 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 Some(selected_gradient) = &mut tool_data.selected_gradient + && let GradientDragTarget::Midpoint(index) = selected_gradient.dragging + { + selected_gradient.gradient.stops.midpoint[index] = 0.5; + selected_gradient.render_gradient(responses); + responses.add(PropertiesPanelMessage::Refresh); + } + self + } + (state, GradientToolMessage::DeleteStop) => { let Some(selected_gradient) = &mut tool_data.selected_gradient else { - return self; + return GradientToolFsmState::Ready { hover_insertion: false }; }; // Skip if invalid gradient if selected_gradient.gradient.stops.len() < 2 { - return self; + return GradientToolFsmState::Ready { hover_insertion: false }; + } + + // If we're in the middle of a drag, abort it first and revert to the initial gradient + if state == GradientToolFsmState::Drawing { + selected_gradient.gradient = selected_gradient.initial_gradient.clone(); + selected_gradient.render_gradient(responses); + responses.add(DocumentMessage::AbortTransaction); + tool_data.snap_manager.cleanup(responses); } responses.add(DocumentMessage::StartTransaction); @@ -451,15 +635,39 @@ impl Fsm for GradientToolFsmState { // Remove the selected point match selected_gradient.dragging { GradientDragTarget::Start => { - selected_gradient.gradient.stops.remove(0); + // Only delete if there's a real color stop at position ~0 (not the endpoint of the line which isn't itself a color stop) + if selected_gradient.gradient.stops.position.first().is_some_and(|&p| p.abs() < f64::EPSILON * 1000.) { + selected_gradient.gradient.stops.remove(0); + } else { + responses.add(DocumentMessage::AbortTransaction); + return GradientToolFsmState::Ready { hover_insertion: false }; + } } GradientDragTarget::End => { - let _ = selected_gradient.gradient.stops.pop(); + // Only delete if there's a real color stop at position ~1 (not the endpoint of the line which isn't itself a color stop) + if selected_gradient.gradient.stops.position.last().is_some_and(|&p| (1. - p).abs() < f64::EPSILON * 1000.) { + let _ = selected_gradient.gradient.stops.pop(); + } else { + responses.add(DocumentMessage::AbortTransaction); + return GradientToolFsmState::Ready { hover_insertion: false }; + } } - GradientDragTarget::Step(index) => { + GradientDragTarget::New => { + responses.add(DocumentMessage::AbortTransaction); + return GradientToolFsmState::Ready { hover_insertion: false }; + } + GradientDragTarget::Stop(index) => { selected_gradient.gradient.stops.remove(index); } - GradientDragTarget::New => {} + GradientDragTarget::Midpoint(index) => { + selected_gradient.gradient.stops.midpoint[index] = 0.5; + selected_gradient.render_gradient(responses); + + responses.add(DocumentMessage::CommitTransaction); + responses.add(PropertiesPanelMessage::Refresh); + + return GradientToolFsmState::Ready { hover_insertion: false }; + } }; // The gradient has only one point and so should become a fill @@ -472,7 +680,7 @@ impl Fsm for GradientToolFsmState { } responses.add(DocumentMessage::CommitTransaction); responses.add(PropertiesPanelMessage::Refresh); - return self; + return GradientToolFsmState::Ready { hover_insertion: false }; } // Find the minimum and maximum positions @@ -497,7 +705,7 @@ impl Fsm for GradientToolFsmState { responses.add(PropertiesPanelMessage::Refresh); tool_data.selected_gradient = None; - self + GradientToolFsmState::Ready { hover_insertion: false } } (_, GradientToolMessage::InsertStop) => { for layer in document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface) { @@ -519,7 +727,7 @@ impl Fsm for GradientToolFsmState { let mut selected_gradient = SelectedGradient::new(gradient, layer, document); // Select the new point - selected_gradient.dragging = GradientDragTarget::Step(index); + selected_gradient.dragging = GradientDragTarget::Stop(index); // Update the layer fill selected_gradient.render_gradient(responses); @@ -554,33 +762,81 @@ impl Fsm for GradientToolFsmState { 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 { + 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); + 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_pos = left + gradient.stops.midpoint[i] * (right - left); + let midpoint_viewport = start.lerp(end, midpoint_pos); + + if midpoint_viewport.distance_squared(mouse) < midpoint_tolerance { + dragging = true; + + tool_data.selected_gradient = Some(SelectedGradient { + layer: Some(layer), + transform, + gradient: gradient.clone(), + dragging: GradientDragTarget::Midpoint(i), + initial_gradient: gradient.clone(), + }); + + break; + } + } + } + // Check for dragging step - 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; - tool_data.selected_gradient = Some(SelectedGradient { - layer: Some(layer), - transform, - gradient: gradient.clone(), - dragging: GradientDragTarget::Step(index), - initial_gradient: gradient.clone(), - }) + if !dragging { + 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(), + }) + } } } // Check dragging start or end handle - 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; - tool_data.selected_gradient = Some(SelectedGradient { - layer: Some(layer), - transform, - gradient: gradient.clone(), - dragging: dragging_target, - initial_gradient: gradient.clone(), - }) + if !dragging { + 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; + tool_data.selected_gradient = Some(SelectedGradient { + layer: Some(layer), + transform, + gradient: gradient.clone(), + dragging: dragging_target, + initial_gradient: gradient.clone(), + }) + } } } @@ -597,7 +853,7 @@ impl Fsm for GradientToolFsmState { transaction_started = true; let mut selected_gradient = SelectedGradient::new(new_gradient, layer, document); - selected_gradient.dragging = GradientDragTarget::Step(index); + selected_gradient.dragging = GradientDragTarget::Stop(index); // 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); @@ -701,14 +957,16 @@ impl Fsm for GradientToolFsmState { (GradientToolFsmState::Drawing, GradientToolMessage::PointerUp) => { responses.add(DocumentMessage::EndTransaction); tool_data.snap_manager.cleanup(responses); - let was_dragging = tool_data.selected_gradient.is_some(); - if !was_dragging - && let Some(selected_layer) = document.click(input, viewport) - && let Some(gradient) = get_gradient(selected_layer, &document.network_interface) - { - tool_data.selected_gradient = Some(SelectedGradient::new(gradient, selected_layer, document)); + // Clear the selection if we were dragging an endpoint of the gradient which isn't a stop + if tool_data.selected_gradient.as_ref().is_some_and(|s| match s.dragging { + GradientDragTarget::Start => !s.gradient.stops.position.first().is_some_and(|&p| p.abs() < f64::EPSILON * 1000.), + GradientDragTarget::End => !s.gradient.stops.position.last().is_some_and(|&p| (1. - p).abs() < f64::EPSILON * 1000.), + _ => false, + }) { + tool_data.selected_gradient = None; } + GradientToolFsmState::Ready { hover_insertion: false } } (GradientToolFsmState::Ready { .. }, GradientToolMessage::PointerMove { .. }) => { @@ -940,9 +1198,9 @@ mod test_gradient { assert_eq!(initial_gradient.stops.len(), 2, "Expected 2 stops, found {}", initial_gradient.stops.len()); editor.select_tool(ToolType::Gradient).await; - editor.move_mouse(50., 0., ModifierKeys::empty(), MouseKeys::empty()).await; - editor.left_mousedown(50., 0., ModifierKeys::empty()).await; - editor.left_mouseup(50., 0., ModifierKeys::empty()).await; + editor.move_mouse(25., 0., ModifierKeys::empty(), MouseKeys::empty()).await; + editor.left_mousedown(25., 0., ModifierKeys::empty()).await; + editor.left_mouseup(25., 0., ModifierKeys::empty()).await; // Check that a new stop has been added let (updated_gradient, _) = get_gradient(&mut editor).await; @@ -950,8 +1208,8 @@ mod test_gradient { let positions: Vec = updated_gradient.stops.iter().map(|stop| stop.position).collect(); assert!( - positions.iter().any(|pos| (pos - 0.5).abs() < 0.1), - "Expected to find a stop near position 0.5, but found: {positions:?}" + positions.iter().any(|pos| (pos - 0.25).abs() < 0.1), + "Expected to find a stop near position 0.25, but found: {positions:?}" ); } @@ -1034,10 +1292,10 @@ mod test_gradient { editor.select_tool(ToolType::Gradient).await; - // Add a middle stop at 50% - editor.move_mouse(50., 0., ModifierKeys::empty(), MouseKeys::empty()).await; - editor.left_mousedown(50., 0., ModifierKeys::empty()).await; - editor.left_mouseup(50., 0., ModifierKeys::empty()).await; + // Add a middle stop at 25% + editor.move_mouse(25., 0., ModifierKeys::empty(), MouseKeys::empty()).await; + editor.left_mousedown(25., 0., ModifierKeys::empty()).await; + editor.left_mouseup(25., 0., ModifierKeys::empty()).await; let (initial_gradient, _) = get_gradient(&mut editor).await; assert_eq!(initial_gradient.stops.len(), 3, "Expected 3 stops, found {}", initial_gradient.stops.len()); @@ -1047,12 +1305,12 @@ mod test_gradient { stops.sort(); let positions: Vec = stops.iter().map(|stop| stop.position).collect(); - assert_stops_at_positions(&positions, &[0., 0.5, 1.], 0.1); + assert_stops_at_positions(&positions, &[0., 0.25, 1.], 0.1); let middle_color = stops.color[1].to_rgba8_srgb(); // Simulate dragging the middle stop to position 0.8 - let click_position = DVec2::new(50., 0.); + let click_position = DVec2::new(25., 0.); editor .mousedown( EditorMouseState { diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index 9c9207ad..38ec8d13 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -232,7 +232,7 @@ impl GradientStops { pub fn sort(&mut self) { let mut indices: Vec = (0..self.position.len()).collect(); - indices.sort_unstable_by(|&a, &b| self.position[a].partial_cmp(&self.position[b]).unwrap()); + indices.sort_unstable_by(|&a, &b| self.position[a].total_cmp(&self.position[b])); self.position = indices.iter().map(|&i| self.position[i]).collect(); self.midpoint = indices.iter().map(|&i| self.midpoint[i]).collect(); self.color = indices.iter().map(|&i| self.color[i]).collect(); @@ -437,9 +437,10 @@ impl Gradient { index += 1; } - // Insert the new stop + // Insert the new stop, duplicating the midpoint ratio of the interval being split + let inherited_midpoint = if index > 0 { self.stops.midpoint[index - 1] } else { 0.5 }; self.stops.position.insert(index, new_position); - self.stops.midpoint.insert(index, 0.5); + self.stops.midpoint.insert(index, inherited_midpoint); self.stops.color.insert(index, new_color); Some(index)