Add draggable diamond midpoint gizmos to the Gradient tool (#3826)

This commit is contained in:
Keavon Chambers 2026-02-24 20:25:15 -08:00 committed by GitHub
parent 7250b091d5
commit 4a6cdffd84
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 401 additions and 75 deletions

View File

@ -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.;

View File

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

View File

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

View File

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

View File

@ -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<ToolMessage, &mut ToolActionMessageContext<'a>> 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<f64> = 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<f64> = 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 {

View File

@ -232,7 +232,7 @@ impl GradientStops {
pub fn sort(&mut self) {
let mut indices: Vec<usize> = (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)