Improve Gradient tool dragging behavior and make hints reactive to current interaction state (#3828)

* Improve Gradient tool dragging behavior and make hints reactive to current interaction state

* Reduce code duplication for drawing stops

* Fix coordinate system issue when PTZ'ing document during drag or autopan
This commit is contained in:
Keavon Chambers 2026-02-24 22:17:29 -08:00 committed by GitHub
parent 4a6cdffd84
commit b4679b0675
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 515 additions and 142 deletions

View File

@ -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),

View File

@ -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]),

View File

@ -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<f64>, 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) {

View File

@ -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<f64>, 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();
}

View File

@ -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<Message>,
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);
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;
}
*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<StopId> = 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<StopId> = 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;
}
overlay_context.gradient_color_stop(start.lerp(end, stop.position), dragging == Some(GradientDragTarget::Stop(index)), &color_to_hex(stop.color), false);
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;
}
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<GradientDragHintState> = 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;
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. {
let drag_target = if stop_position.abs() < f64::EPSILON * 1000. {
GradientDragTarget::Start
} else if (1. - stop.position).abs() < f64::EPSILON * 1000. {
} 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<Message>) {
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;

View File

@ -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());