use super::tool_prelude::*; 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::node_graph::document_node_definitions::DefinitionIdentifier; use crate::messages::portfolio::document::overlays::utility_types::{GizmoEmphasis, OverlayContext}; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::{FlowType, NodeNetworkInterface}; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; use crate::messages::tool::common_functionality::graph_modification_utils::{self, NodeGraphLayer, get_gradient_table}; use crate::messages::tool::common_functionality::snapping::{SnapCandidatePoint, SnapConstraint, SnapData, SnapManager, SnapTypeConfiguration}; use graph_craft::document::value::TaggedValue; use graphene_std::raster::color::Color; use graphene_std::vector::style::{Fill, FillChoice, Gradient, GradientSpreadMethod, GradientStop, GradientStops, GradientType}; #[derive(Default, ExtractField)] pub struct GradientTool { fsm_state: GradientToolFsmState, data: GradientToolData, options: GradientOptions, } #[derive(Default)] pub struct GradientOptions { gradient_type: GradientType, spread_method: GradientSpreadMethod, } #[impl_message(Message, ToolMessage, Gradient)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] pub enum GradientToolMessage { // Standard messages Abort, Overlays { context: OverlayContext }, SelectionChanged, WorkingColorChanged, // Tool-specific messages DeleteStop, DoubleClick, InsertStop, PointerDown, PointerMove { constrain_axis: Key, lock_angle: Key }, PointerOutsideViewport { constrain_axis: Key, lock_angle: Key }, PointerUp, StartTransactionForColorStop, CommitTransactionForColorStop, CloseStopColorPicker, UpdateStopColor { color: Color }, UpdateStops { stops: GradientStops }, UpdateOptions { options: GradientOptionsUpdate }, } #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(PartialEq, Eq, Clone, Debug, Hash, serde::Serialize, serde::Deserialize)] pub enum GradientOptionsUpdate { Type(GradientType), ReverseStops, ReverseDirection, SetSpreadMethod(GradientSpreadMethod), } impl ToolMetadata for GradientTool { fn icon_name(&self) -> String { "GeneralGradientTool".into() } fn tooltip_label(&self) -> String { "Gradient Tool".into() } fn tool_type(&self) -> crate::messages::tool::utility_types::ToolType { ToolType::Gradient } } #[message_handler_data] impl<'a> MessageHandler> for GradientTool { fn process_message(&mut self, message: ToolMessage, responses: &mut VecDeque, context: &mut ToolActionMessageContext<'a>) { match message { ToolMessage::Gradient(GradientToolMessage::UpdateOptions { options }) => match options { GradientOptionsUpdate::Type(gradient_type) => { self.options.gradient_type = gradient_type; apply_gradient_update(&mut self.data, context, responses, |g| g.gradient_type != gradient_type, |g| g.gradient_type = gradient_type); responses.add(ToolMessage::UpdateHints); responses.add(ToolMessage::UpdateCursor); } GradientOptionsUpdate::ReverseStops => { apply_gradient_update(&mut self.data, context, responses, |_| true, |g| g.stops = g.stops.reversed()); } GradientOptionsUpdate::ReverseDirection => { apply_gradient_update(&mut self.data, context, responses, |_| true, |g| std::mem::swap(&mut g.start, &mut g.end)); } GradientOptionsUpdate::SetSpreadMethod(spread_method) => { self.options.spread_method = spread_method; apply_gradient_update(&mut self.data, context, responses, |g| g.spread_method != spread_method, |g| g.spread_method = spread_method); } }, ToolMessage::Gradient(GradientToolMessage::StartTransactionForColorStop) => { if self.data.color_picker_transaction_open { responses.add(DocumentMessage::EndTransaction); } responses.add(DocumentMessage::StartTransaction); self.data.color_picker_transaction_open = true; } ToolMessage::Gradient(GradientToolMessage::CommitTransactionForColorStop) => { if self.data.color_picker_transaction_open { responses.add(DocumentMessage::EndTransaction); self.data.color_picker_transaction_open = false; } } ToolMessage::Gradient(GradientToolMessage::UpdateStopColor { color }) => { if let Some(stop_index) = self.data.color_picker_editing_color_stop && let Some(selected_gradient) = &mut self.data.selected_gradient && stop_index < selected_gradient.gradient.stops.color.len() { selected_gradient.gradient.stops.color[stop_index] = color; selected_gradient.render_gradient(responses); responses.add(PropertiesPanelMessage::Refresh); } } ToolMessage::Gradient(GradientToolMessage::UpdateStops { stops }) => { apply_stops_update(&mut self.data, context, responses, stops); } ToolMessage::Gradient(GradientToolMessage::CloseStopColorPicker) => { if self.data.color_picker_transaction_open { responses.add(DocumentMessage::EndTransaction); self.data.color_picker_transaction_open = false; } self.data.color_picker_editing_color_stop = None; } ToolMessage::Gradient(GradientToolMessage::WorkingColorChanged) => { let primary = context.global_tool_data.primary_color; let secondary = context.global_tool_data.secondary_color; if self.data.primary_color != primary || self.data.secondary_color != secondary { self.data.primary_color = primary; self.data.secondary_color = secondary; if !self.data.has_selected_gradient { responses.add(ToolMessage::RefreshToolOptions); } } } _ => { self.fsm_state.process_event(message, &mut self.data, context, &self.options, responses, false); // Reading from the layer (not from the in-progress drag state) keeps the control bar widgets current across selection changes, not just drags let (current_layer, current_gradient) = current_layer_and_gradient(context.document); let mut needs_refresh = false; if let Some(gradient) = ¤t_gradient { if self.options.gradient_type != gradient.gradient_type { self.options.gradient_type = gradient.gradient_type; needs_refresh = true; } if self.options.spread_method != gradient.spread_method { self.options.spread_method = gradient.spread_method; needs_refresh = true; } } let has_gradient = current_gradient.is_some(); if has_gradient != self.data.has_selected_gradient { self.data.has_selected_gradient = has_gradient; needs_refresh = true; } let new_stops = current_gradient.as_ref().map(|gradient| gradient.stops.clone()); if self.data.current_gradient_stops != new_stops { self.data.current_gradient_stops = new_stops; needs_refresh = true; } let new_orientation = match (current_layer, ¤t_gradient) { (Some(layer), Some(gradient)) => { let transform = gradient_space_transform(layer, context.document); graph_modification_utils::gradient_orientation_rightward(gradient.start, gradient.end, transform) } _ => true, }; if new_orientation != self.data.gradient_orientation_rightward { self.data.gradient_orientation_rightward = new_orientation; needs_refresh = true; } if needs_refresh { responses.add(ToolMessage::RefreshToolOptions); } } } } advertise_actions!(GradientToolMessageDiscriminant; PointerDown, PointerUp, PointerMove, DoubleClick, Abort, DeleteStop, ); } impl LayoutHolder for GradientTool { fn layout(&self) -> Layout { let mut widgets: Vec = Vec::new(); let gradient_type = RadioInput::new(vec![ RadioEntryData::new("Linear").label("Linear").tooltip_label("Linear Gradient").on_update(move |_| { GradientToolMessage::UpdateOptions { options: GradientOptionsUpdate::Type(GradientType::Linear), } .into() }), RadioEntryData::new("Radial").label("Radial").tooltip_label("Radial Gradient").on_update(move |_| { GradientToolMessage::UpdateOptions { options: GradientOptionsUpdate::Type(GradientType::Radial), } .into() }), ]) .selected_index(Some((self.options.gradient_type == GradientType::Radial) as u32)) .widget_instance(); let stops_value = self.data.current_gradient_stops.clone().map(FillChoice::Gradient).unwrap_or_else(|| { FillChoice::Gradient(GradientStops::new([ GradientStop { position: 0., midpoint: 0.5, color: self.data.primary_color, }, GradientStop { position: 1., midpoint: 0.5, color: self.data.secondary_color, }, ])) }); let stops_widget = ColorInput::new(stops_value) .allow_none(false) .disabled(!self.data.has_selected_gradient) .tooltip_label("Gradient Stops") .tooltip_description("Edit the gradient's color stops.") .on_update(|input: &ColorInput| { let stops = input.value.as_gradient().cloned().unwrap_or_default(); GradientToolMessage::UpdateStops { stops }.into() }) .on_commit(|_| DocumentMessage::AddTransaction.into()) .widget_instance(); let reverse_stops = IconButton::new("Reverse", 24) .tooltip_label("Reverse Stops") .tooltip_description("Reverse the gradient color stops.") .disabled(!self.data.has_selected_gradient) .on_update(|_| { GradientToolMessage::UpdateOptions { options: GradientOptionsUpdate::ReverseStops, } .into() }) .widget_instance(); let spread_method = RadioInput::new(vec![ RadioEntryData::new("Pad").label("Pad").tooltip_label("Pad Spread Method").on_update(move |_| { GradientToolMessage::UpdateOptions { options: GradientOptionsUpdate::SetSpreadMethod(GradientSpreadMethod::Pad), } .into() }), RadioEntryData::new("Reflect").label("Reflect").tooltip_label("Reflect Spread Method").on_update(move |_| { GradientToolMessage::UpdateOptions { options: GradientOptionsUpdate::SetSpreadMethod(GradientSpreadMethod::Reflect), } .into() }), RadioEntryData::new("Repeat").label("Repeat").tooltip_label("Repeat Spread Method").on_update(move |_| { GradientToolMessage::UpdateOptions { options: GradientOptionsUpdate::SetSpreadMethod(GradientSpreadMethod::Repeat), } .into() }), ]) .selected_index(Some(self.options.spread_method as u32)) .widget_instance(); let reverse_direction_icon = if self.data.gradient_orientation_rightward { "ReverseRadialGradientToRight" } else { "ReverseRadialGradientToLeft" }; let reverse_direction = IconButton::new(reverse_direction_icon, 24) .tooltip_label("Reverse Direction") .tooltip_description("Reverse which end the gradient radiates from.") .disabled(!self.data.has_selected_gradient) .on_update(|_| { GradientToolMessage::UpdateOptions { options: GradientOptionsUpdate::ReverseDirection, } .into() }) .widget_instance(); widgets.extend([ stops_widget, Separator::new(SeparatorStyle::Related).widget_instance(), reverse_stops, Separator::new(SeparatorStyle::Unrelated).widget_instance(), gradient_type, Separator::new(SeparatorStyle::Unrelated).widget_instance(), spread_method, Separator::new(SeparatorStyle::Related).widget_instance(), reverse_direction, ]); Layout(vec![LayoutGroup::row(widgets)]) } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum GradientToolFsmState { Ready { hovering: GradientHoverTarget, selected: GradientSelectedTarget }, Drawing { drag_hint: GradientDragHintState }, } impl Default for GradientToolFsmState { fn default() -> Self { Self::Ready { hovering: GradientHoverTarget::None, selected: GradientSelectedTarget::None, } } } /// Computes the transform from gradient space to viewport space (where gradient space is 0..1). fn gradient_space_transform(layer: LayerNodeIdentifier, document: &DocumentMessageHandler) -> DAffine2 { graph_modification_utils::gradient_space_transform(layer, &document.network_interface) } // TODO: Remove this whole function once all gradients are `Table` fn get_gradient(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { if let Some(stops_table) = get_gradient_table(layer, network_interface) { let stops = stops_table.element(0).cloned().unwrap_or_default(); let GradientChainState { transform, gradient_type, spread_method, } = read_gradient_chain_state(layer, network_interface); return Some(Gradient { stops, gradient_type, spread_method, start: transform.transform_point2(DVec2::ZERO), end: transform.transform_point2(DVec2::X), }); } graph_modification_utils::get_gradient(layer, network_interface) } #[derive(Clone, Copy, Debug)] struct GradientChainState { transform: DAffine2, gradient_type: GradientType, spread_method: GradientSpreadMethod, } /// Resolve the gradient transform, type, and spread method by walking the chain feeding the layer. Transform composes all /// 'Transform' nodes. Type and spread method come from the closest-to-layer node of each kind, or the type default. fn read_gradient_chain_state(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> GradientChainState { let transform_reference = DefinitionIdentifier::ProtoNode(graphene_std::transform_nodes::transform::IDENTIFIER); let gradient_type_reference = DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_type::IDENTIFIER); let spread_method_reference = DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::spread_method::IDENTIFIER); let mut transforms_downstream_to_upstream: Vec = Vec::new(); let mut gradient_type: Option = None; let mut spread_method: Option = None; for node_id in network_interface .upstream_flow_back_from_nodes(vec![layer.to_node()], &[], FlowType::HorizontalFlow) .skip(1) .take_while(|node_id| !network_interface.is_layer(node_id, &[])) { let Some(reference) = network_interface.reference(&node_id, &[]) else { continue }; let Some(document_node) = network_interface.document_network().nodes.get(&node_id) else { continue; }; if reference == transform_reference { transforms_downstream_to_upstream.push(read_transform_node_value(&document_node.inputs)); } else if reference == gradient_type_reference && gradient_type.is_none() && let Some(TaggedValue::GradientType(value)) = document_node.inputs.get(1).and_then(|input| input.as_value()) { gradient_type = Some(*value); } else if reference == spread_method_reference && spread_method.is_none() && let Some(TaggedValue::GradientSpreadMethod(value)) = document_node.inputs.get(1).and_then(|input| input.as_value()) { spread_method = Some(*value); } } // Iteration order [T_n, ..., T_1] is the matrix-product order, so the fold yields T_n * ... * T_1 let composed_transform = transforms_downstream_to_upstream.into_iter().fold(DAffine2::IDENTITY, |acc, matrix| acc * matrix); GradientChainState { transform: composed_transform, gradient_type: gradient_type.unwrap_or_default(), spread_method: spread_method.unwrap_or_default(), } } /// Reconstruct the `DAffine2` produced by a 'Transform' node from its translation, rotation, scale, and skew inputs. fn read_transform_node_value(inputs: &[graph_craft::document::NodeInput]) -> DAffine2 { let translation = inputs .get(1) .and_then(|input| input.as_value()) .and_then(|value| if let TaggedValue::DVec2(v) = value { Some(*v) } else { None }) .unwrap_or(DVec2::ZERO); let rotation_degrees = inputs .get(2) .and_then(|input| input.as_value()) .and_then(|value| if let TaggedValue::F64(v) = value { Some(*v) } else { None }) .unwrap_or(0.); let scale = inputs .get(3) .and_then(|input| input.as_value()) .and_then(|value| if let TaggedValue::DVec2(v) = value { Some(*v) } else { None }) .unwrap_or(DVec2::ONE); let skew = inputs .get(4) .and_then(|input| input.as_value()) .and_then(|value| if let TaggedValue::DVec2(v) = value { Some(*v) } else { None }) .unwrap_or(DVec2::ZERO); let trs = DAffine2::from_scale_angle_translation(scale, rotation_degrees.to_radians(), translation); let skew_matrix = DAffine2::from_cols_array(&[1., skew.y.to_radians().tan(), skew.x.to_radians().tan(), 1., 0., 0.]); trs * skew_matrix } /// 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, Stop(usize), Midpoint(usize), New, } /// Contains information about the selected gradient handle #[derive(Clone, Debug, Default)] struct SelectedGradient { layer: Option, transform: DAffine2, gradient: Gradient, dragging: GradientDragTarget, initial_gradient: Gradient, // TODO: Remove (and the matching branches in `render_gradient` / pointer-up) once `Table` replaces legacy `Fill::Gradient` is_gradient_table: bool, } fn calculate_insertion(start: DVec2, end: DVec2, stops: &GradientStops, mouse: DVec2) -> Option { 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); if distance.abs() < SEGMENT_INSERTION_DISTANCE && (0. ..=1.).contains(&projection) { for stop in stops { let stop_pos = start.lerp(end, stop.position); if stop_pos.distance_squared(mouse) < (MANIPULATOR_GROUP_MARKER_SIZE * 2.).powi(2) { return None; } } if start.distance_squared(mouse) < (MANIPULATOR_GROUP_MARKER_SIZE * 2.).powi(2) || end.distance_squared(mouse) < (MANIPULATOR_GROUP_MARKER_SIZE * 2.).powi(2) { 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); } None } impl SelectedGradient { pub fn new(gradient: Gradient, layer: LayerNodeIdentifier, document: &DocumentMessageHandler) -> Self { let transform = gradient_space_transform(layer, document); let is_gradient_table = get_gradient_table(layer, &document.network_interface).is_some(); Self { layer: Some(layer), transform, gradient: gradient.clone(), dragging: GradientDragTarget::End, initial_gradient: gradient, is_gradient_table, } } #[allow(clippy::too_many_arguments)] pub fn update_gradient( &mut self, mut mouse: DVec2, responses: &mut VecDeque, snap_rotate: bool, lock_angle: bool, gradient_type: GradientType, drag_start: DVec2, snap_data: SnapData, snap_manager: &mut SnapManager, gradient_angle: &mut f64, ) { if mouse.distance(drag_start) < DRAG_THRESHOLD { self.gradient = self.initial_gradient.clone(); self.render_gradient(responses); return; } self.gradient.gradient_type = gradient_type; 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 { drag_start } else { self.transform.transform_point2(self.gradient.start) }; let delta = point - mouse; 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); let point_candidate = SnapCandidatePoint::gradient_handle(document_mouse); let snapped = snap_manager.free_snap(&snap_data, &point_candidate, SnapTypeConfiguration::default()); if snapped.is_snapped() { mouse = document_to_viewport.transform_point2(snapped.snapped_point_document); } snap_manager.update_indicator(snapped); } let transformed_mouse = self.transform.inverse().transform_point2(mouse); match self.dragging { GradientDragTarget::Start => { self.gradient.start = transformed_mouse; } GradientDragTarget::End => { self.gradient.end = transformed_mouse; } GradientDragTarget::New => { self.gradient.start = self.transform.inverse().transform_point2(drag_start); self.gradient.end = transformed_mouse; } 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), ); 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 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) / line_length; if !new_pos.is_finite() { self.render_gradient(responses); return; } // 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 last_index = self.gradient.stops.len() - 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; let new_position = self.gradient.stops.position[s]; let new_color = self.gradient.stops.color[s]; self.gradient.stops.sort(); 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); } /// Update the layer fill to the current gradient pub fn render_gradient(&mut self, responses: &mut VecDeque) { if let Some(layer) = self.layer { // TODO: Drop the `Fill::Gradient` branch when all gradients become `Table` if self.is_gradient_table { dispatch_gradient_writes(layer, &self.gradient, responses); } else { responses.add(GraphOperationMessage::FillSet { layer, fill: Fill::Gradient(self.gradient.clone()), }); } } } } /// Send the four per-attribute graph operations that mirror the in-memory `Gradient` onto the chain feeding the layer. fn dispatch_gradient_writes(layer: LayerNodeIdentifier, gradient: &Gradient, responses: &mut VecDeque) { responses.add(GraphOperationMessage::GradientStopsSet { layer, stops: gradient.stops.clone() }); responses.add(GraphOperationMessage::GradientLineSet { layer, start: gradient.start, end: gradient.end, }); responses.add(GraphOperationMessage::GradientTypeSet { layer, gradient_type: gradient.gradient_type, }); responses.add(GraphOperationMessage::GradientSpreadMethodSet { layer, spread_method: gradient.spread_method, }); } impl GradientTool { /// Get the gradient type of the selected gradient (if it exists) pub fn selected_gradient(&self) -> Option { self.data.selected_gradient.as_ref().map(|selected| selected.gradient.gradient_type) } } impl ToolTransition for GradientTool { fn event_to_message_map(&self) -> EventToMessageMap { EventToMessageMap { tool_abort: Some(GradientToolMessage::Abort.into()), selection_changed: Some(GradientToolMessage::SelectionChanged.into()), working_color_changed: Some(GradientToolMessage::WorkingColorChanged.into()), overlay_provider: Some(|context| GradientToolMessage::Overlays { context }.into()), ..Default::default() } } } #[derive(Clone, Debug, Default)] struct GradientToolData { selected_gradient: Option, snap_manager: SnapManager, drag_start: DVec2, auto_panning: AutoPanning, auto_pan_shift: DVec2, gradient_angle: f64, has_selected_gradient: bool, /// Cached stops of the currently selected layer's gradient, mirrored into the control-bar widget. Independent of any /// in-progress drag (which uses `selected_gradient`) so it stays current after selection changes too. current_gradient_stops: Option, /// Cached viewport-space orientation (true = predominantly rightward) of the selected gradient line. /// Used to refresh the control bar's "Reverse Direction" icon only when the line's apparent direction flips. gradient_orientation_rightward: bool, /// Cached working colors, mirrored from `DocumentToolData` via the `WorkingColorChanged` event, used as the default gradient colors. primary_color: Color, secondary_color: Color, color_picker_editing_color_stop: Option, color_picker_transaction_open: bool, } impl Fsm for GradientToolFsmState { type ToolData = GradientToolData; type ToolOptions = GradientOptions; fn transition( self, event: ToolMessage, tool_data: &mut Self::ToolData, tool_action_data: &mut ToolActionMessageContext, tool_options: &Self::ToolOptions, responses: &mut VecDeque, ) -> Self { let ToolActionMessageContext { document, global_tool_data, input, viewport, .. } = tool_action_data; let ToolMessage::Gradient(event) = event else { return self }; match (self, event) { (_, GradientToolMessage::Overlays { context: mut overlay_context }) => { let selected = tool_data.selected_gradient.as_ref(); 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 dragging = selected .filter(|selected| selected.layer.is_some_and(|selected_layer| selected_layer == layer)) .map(|selected| selected.dragging); let gradient = if matches!(self, GradientToolFsmState::Drawing { .. }) && dragging.is_some() && let Some(selected_gradient) = selected.filter(|s| s.layer == Some(layer)) { &selected_gradient.gradient } else { &gradient }; let Gradient { start, end, stops, .. } = gradient; let (start, end) = (transform.transform_point2(*start), transform.transform_point2(*end)); fn color_to_hex(color: graphene_std::Color) -> String { format!("#{}", color.to_rgb_hex_srgb_from_gamma()) } 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.); overlay_context.line(start, end, None, None); // Determine which stop is selected (being dragged) and hovered (closest to mouse) // so they can be drawn last to appear on top of other overlapping stops let selected_stop_id: Option = match dragging { Some(GradientDragTarget::Start) => Some(StopId::Start), Some(GradientDragTarget::End) => Some(StopId::End), Some(GradientDragTarget::Stop(0)) if first_at_start => Some(StopId::Start), Some(GradientDragTarget::Stop(i)) if last_at_end && i == stops.len() - 1 => Some(StopId::End), Some(GradientDragTarget::Stop(i)) => Some(StopId::Middle(i)), _ => None, }; let stop_tolerance = (MANIPULATOR_GROUP_MARKER_SIZE * 2.).powi(2); let hovered_stop_id: Option = if !matches!(self, GradientToolFsmState::Drawing { .. }) { // Find the closest stop to the mouse (matching the click detection logic) let mut best: Option<(f64, StopId)> = None; let mut check = |dist_sq: f64, id: StopId| { if dist_sq < stop_tolerance && best.as_ref().is_none_or(|&(d, _)| dist_sq < d) { best = Some((dist_sq, id)); } }; check(start.distance_squared(mouse), StopId::Start); check(end.distance_squared(mouse), StopId::End); for (index, stop) in stops.iter().enumerate() { if stop.position.abs() < f64::EPSILON * 1000. || (1. - stop.position).abs() < f64::EPSILON * 1000. { continue; } check(start.lerp(end, stop.position).distance_squared(mouse), StopId::Middle(index)); } best.map(|(_, id)| id) } else { None }; // Draw order: regular stops first, then selected, then hovered (so hovered appears on top) let is_deferred = |id: StopId| -> bool { Some(id) == selected_stop_id || Some(id) == hovered_stop_id }; let emphasis_for = |id: StopId| -> GizmoEmphasis { if Some(id) == selected_stop_id { GizmoEmphasis::Active } else if Some(id) == hovered_stop_id { GizmoEmphasis::Hovered } else { GizmoEmphasis::Regular } }; let mut draw_stop = |id: StopId, emphasis: GizmoEmphasis| match id { StopId::Start => overlay_context.gradient_color_stop(start, emphasis, &start_hex, !first_at_start), StopId::End => overlay_context.gradient_color_stop(end, emphasis, &end_hex, !last_at_end), StopId::Middle(i) => { if let Some(stop) = stops.iter().nth(i) { overlay_context.gradient_color_stop(start.lerp(end, stop.position), emphasis, &color_to_hex(stop.color), false); } } }; // Draw regular (non-deferred) stops if !is_deferred(StopId::Start) { draw_stop(StopId::Start, emphasis_for(StopId::Start)); } if !is_deferred(StopId::End) { draw_stop(StopId::End, emphasis_for(StopId::End)); } for (index, stop) in stops.iter().enumerate() { if stop.position.abs() < f64::EPSILON * 1000. || (1. - stop.position).abs() < f64::EPSILON * 1000. { continue; } 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]; 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); 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 !matches!(self, GradientToolFsmState::Drawing { .. }) && calculate_insertion(start, end, stops, mouse).is_some() && let Some(dir) = (end - start).try_normalize() { let perp = dir.perp(); // 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.), ); } } let snap_data = SnapData::new(document, input, viewport); tool_data.snap_manager.draw_overlays(snap_data, &mut overlay_context); // Update color picker position if active (keeps it anchored to the stop during pan/zoom) if let Some(stop_index) = tool_data.color_picker_editing_color_stop && let Some(selected_gradient) = tool_data.selected_gradient.as_ref() && let Some(layer) = selected_gradient.layer { let transform = gradient_space_transform(layer, document); let gradient = &selected_gradient.gradient; if stop_index < gradient.stops.position.len() { let color = gradient.stops.color[stop_index].to_gamma_srgb(); let position = gradient.stops.position[stop_index]; let position = transform.transform_point2(gradient.start.lerp(gradient.end, position)).into(); responses.add(FrontendMessage::UpdateGradientStopColorPickerPosition { color, position }); } } self } (GradientToolFsmState::Ready { .. }, GradientToolMessage::SelectionChanged) => { if tool_data.color_picker_editing_color_stop.is_some() { if tool_data.color_picker_transaction_open { responses.add(DocumentMessage::EndTransaction); tool_data.color_picker_transaction_open = false; } tool_data.color_picker_editing_color_stop = None; } tool_data.selected_gradient = None; 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 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 { match selected_gradient.dragging { GradientDragTarget::Midpoint(index) => { selected_gradient.gradient.stops.midpoint[index] = 0.5; selected_gradient.render_gradient(responses); responses.add(PropertiesPanelMessage::Refresh); } GradientDragTarget::Start | GradientDragTarget::End | GradientDragTarget::Stop(_) => { // Find the stop index from the drag target let stop_index = match selected_gradient.dragging { GradientDragTarget::Stop(i) => Some(i), GradientDragTarget::Start => selected_gradient.gradient.stops.position.iter().position(|&p| p.abs() < f64::EPSILON * 1000.), GradientDragTarget::End => selected_gradient.gradient.stops.position.iter().position(|&p| (1. - p).abs() < f64::EPSILON * 1000.), _ => None, }; if let Some(stop_index) = stop_index && stop_index < selected_gradient.gradient.stops.color.len() { // Dismiss any existing color picker first if tool_data.color_picker_editing_color_stop.is_some() && tool_data.color_picker_transaction_open { responses.add(DocumentMessage::EndTransaction); tool_data.color_picker_transaction_open = false; } let stop_pos = selected_gradient.gradient.stops.position[stop_index]; let viewport_pos = selected_gradient .transform .transform_point2(selected_gradient.gradient.start.lerp(selected_gradient.gradient.end, stop_pos)); let position = viewport_pos.into(); let color = selected_gradient.gradient.stops.color[stop_index].to_gamma_srgb(); tool_data.color_picker_editing_color_stop = Some(stop_index); responses.add(FrontendMessage::UpdateGradientStopColorPickerPosition { color, position }); } } _ => {} } } 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 ready_default; }; // Skip if invalid gradient if selected_gradient.gradient.stops.len() < 2 { return ready_default; } // If we're in the middle of a drag, abort it first and revert to the initial gradient if matches!(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); // Remove the selected point match selected_gradient.dragging { GradientDragTarget::Start => { // 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 ready_default; } } GradientDragTarget::End => { // 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 ready_default; } } GradientDragTarget::New => { responses.add(DocumentMessage::AbortTransaction); return ready_default; } GradientDragTarget::Stop(index) => { selected_gradient.gradient.stops.remove(index); } 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 ready_default; } }; // The gradient has only one point and so should become a fill // TODO: Drop the legacy `Fill::Solid` branch when all gradients become `Table` if selected_gradient.gradient.stops.len() == 1 { if selected_gradient.is_gradient_table { selected_gradient.render_gradient(responses); } else if let Some(layer) = selected_gradient.layer { responses.add(GraphOperationMessage::FillSet { layer, fill: Fill::Solid(selected_gradient.gradient.stops.color[0]), }); } responses.add(DocumentMessage::CommitTransaction); responses.add(PropertiesPanelMessage::Refresh); return ready_default; } // Find the minimum and maximum positions let min_position = selected_gradient.gradient.stops.position.iter().copied().reduce(f64::min).expect("No min"); 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)); selected_gradient.gradient.start = transform.inverse().transform_point2(new_start); selected_gradient.gradient.end = transform.inverse().transform_point2(new_end); // Remap the positions for position in selected_gradient.gradient.stops.position.iter_mut() { *position = (*position - min_position) / (max_position - min_position); } // Render the new gradient selected_gradient.render_gradient(responses); responses.add(DocumentMessage::CommitTransaction); responses.add(PropertiesPanelMessage::Refresh); tool_data.selected_gradient = None; ready_default } (_, GradientToolMessage::InsertStop) => { for layer in document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface) { let Some(mut gradient) = get_gradient(layer, &document.network_interface) else { continue }; // TODO: This transform is incorrect. I think this is since it is based on the Footprint which has not been updated yet let transform = gradient_space_transform(layer, document); let mouse = input.mouse.position; let (start, end) = (transform.transform_point2(gradient.start), transform.transform_point2(gradient.end)); // Compute the distance from the mouse to the gradient line in viewport space let distance = (end - start).angle_to(mouse - start).sin() * (mouse - start).length(); // If click is on the line then insert point if distance < (SELECTION_THRESHOLD * 2.) { // Try and insert the new stop if let Some(index) = gradient.insert_stop(mouse, transform) { responses.add(DocumentMessage::StartTransaction); let mut selected_gradient = SelectedGradient::new(gradient, layer, document); // Select the new point selected_gradient.dragging = GradientDragTarget::Stop(index); // Update the layer fill selected_gradient.render_gradient(responses); tool_data.selected_gradient = Some(selected_gradient); responses.add(DocumentMessage::CommitTransaction); break; } } } self } (GradientToolFsmState::Ready { .. }, GradientToolMessage::PointerDown) => { let document_to_viewport = document.metadata().document_to_viewport; let mut mouse = input.mouse.position; let snap_data = SnapData::new(document, input, viewport); let point = SnapCandidatePoint::gradient_handle(document_to_viewport.inverse().transform_point2(mouse)); let snapped = tool_data.snap_manager.free_snap(&snap_data, &point, SnapTypeConfiguration::default()); if snapped.is_snapped() { mouse = document_to_viewport.transform_point2(snapped.snapped_point_document); } 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 drag_hint: Option = None; let mut transaction_started = false; for layer in document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface) { let Some(gradient) = get_gradient(layer, &document.network_interface) else { continue }; let transform = gradient_space_transform(layer, document); let is_gradient_table = get_gradient_table(layer, &document.network_interface).is_some(); // Check for dragging a midpoint diamond 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); 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 { let resettable = midpoint_is_resettable(gradient.stops.midpoint[i]); drag_hint = Some(GradientDragHintState::Midpoint { resettable }); tool_data.selected_gradient = Some(SelectedGradient { layer: Some(layer), transform, gradient: gradient.clone(), dragging: GradientDragTarget::Midpoint(i), initial_gradient: gradient.clone(), is_gradient_table, }); break; } } } // 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)); let dist_sq = pos.distance_squared(mouse); if dist_sq < tolerance && best.as_ref().is_none_or(|&(best_dist, _)| dist_sq < best_dist) { best = Some((dist_sq, index)); } } if let Some((_, index)) = best { let stop_position = gradient.stops.position[index]; // Stops at position 0 or 1 are locked endpoints: dragging moves the // gradient line endpoint geometry (start/end) instead of stop position let drag_target = if stop_position.abs() < f64::EPSILON * 1000. { GradientDragTarget::Start } else if (1. - stop_position).abs() < f64::EPSILON * 1000. { GradientDragTarget::End } else { GradientDragTarget::Stop(index) }; drag_hint = Some(match drag_target { GradientDragTarget::Start | GradientDragTarget::End => GradientDragHintState::EndStop, _ => GradientDragHintState::Stop, }); tool_data.selected_gradient = Some(SelectedGradient { layer: Some(layer), transform, gradient: gradient.clone(), dragging: drag_target, initial_gradient: gradient.clone(), is_gradient_table, }); } } // Check dragging start or end handle 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 { drag_hint = Some(GradientDragHintState::Endpoint); tool_data.selected_gradient = Some(SelectedGradient { layer: Some(layer), transform, gradient: gradient.clone(), dragging: dragging_target, initial_gradient: gradient.clone(), is_gradient_table, }) } } } // Insert stop if clicking on line 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); if distance.abs() < SEGMENT_INSERTION_DISTANCE && (0. ..=1.).contains(&projection) { let mut new_gradient = gradient.clone(); if let Some(index) = new_gradient.insert_stop(mouse, transform) { responses.add(DocumentMessage::StartTransaction); transaction_started = true; let mut selected_gradient = SelectedGradient::new(new_gradient, layer, document); 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); drag_hint = Some(GradientDragHintState::Stop); } } } } // Initialize `gradient_angle` from the existing gradient so Ctrl (lock angle) works from the first mouse move if let Some(selected_gradient) = &tool_data.selected_gradient { let (vp_start, vp_end) = ( selected_gradient.transform.transform_point2(selected_gradient.gradient.start), selected_gradient.transform.transform_point2(selected_gradient.gradient.end), ); let delta = match selected_gradient.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); // Table-based gradients render no geometry, so a click on empty canvas yields no layer. Fall back to a // selected gradient-table layer so the user can drag a fresh gradient line anywhere. let selected_layer = document.click_based_on_position(document_mouse).or_else(|| { document .network_interface .selected_nodes() .selected_visible_layers(&document.network_interface) .find(|&layer| get_gradient_table(layer, &document.network_interface).is_some()) }); // Apply the gradient to the selected layer if let Some(layer) = selected_layer { // Add check for raster layer if NodeGraphLayer::is_raster_layer(layer, &mut document.network_interface) { 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()]; responses.add(NodeGraphMessage::SelectedNodesSet { nodes }); } let gradient = if let Some(gradient) = get_gradient(layer, &document.network_interface) { // Use the already existing gradient if it exists gradient.clone() } else { // Generate a new gradient running primary → secondary so the default working colors // (primary = black, secondary = white) produce the expected black-to-white gradient Gradient::new( DVec2::ZERO, global_tool_data.primary_color, DVec2::ONE, global_tool_data.secondary_color, tool_options.gradient_type, tool_options.spread_method, ) }; let mut selected_gradient = SelectedGradient::new(gradient, layer, document); selected_gradient.dragging = GradientDragTarget::New; tool_data.selected_gradient = Some(selected_gradient); GradientToolFsmState::Drawing { drag_hint: GradientDragHintState::NewGradient, } } else { GradientToolFsmState::Ready { hovering: GradientHoverTarget::None, selected: GradientSelectedTarget::None, } } }; if matches!(gradient_state, GradientToolFsmState::Drawing { .. }) && !transaction_started { responses.add(DocumentMessage::StartTransaction); } responses.add(OverlaysMessage::Draw); gradient_state } (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 document_to_viewport = document.metadata().document_to_viewport; let drag_start_viewport = document_to_viewport.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, drag_start_viewport, snap_data, &mut tool_data.snap_manager, &mut tool_data.gradient_angle, ); } // Auto-panning let messages = [ 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 { drag_hint } } (GradientToolFsmState::Drawing { drag_hint }, GradientToolMessage::PointerOutsideViewport { .. }) => { // Auto-panning if let Some(shift) = tool_data.auto_panning.shift_viewport(input, viewport, responses) { tool_data.auto_pan_shift += shift; } GradientToolFsmState::Drawing { drag_hint } } (state, GradientToolMessage::PointerOutsideViewport { constrain_axis, lock_angle }) => { // Auto-panning let messages = [ 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) => { responses.add(DocumentMessage::EndTransaction); tool_data.snap_manager.cleanup(responses); // 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; } let selected = compute_selected_target(tool_data); GradientToolFsmState::Ready { hovering: GradientHoverTarget::None, selected, } } (GradientToolFsmState::Ready { .. }, GradientToolMessage::PointerMove { .. }) => { let mouse = input.mouse.position; 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 { hovering, selected } } (GradientToolFsmState::Drawing { .. }, GradientToolMessage::Abort) => { responses.add(DocumentMessage::AbortTransaction); tool_data.snap_manager.cleanup(responses); tool_data.selected_gradient = None; responses.add(OverlaysMessage::Draw); dismiss_color_stop_color_picker(tool_data, responses); GradientToolFsmState::Ready { hovering: GradientHoverTarget::None, selected: GradientSelectedTarget::None, } } (_, GradientToolMessage::Abort) => { dismiss_color_stop_color_picker(tool_data, responses); GradientToolFsmState::Ready { hovering: GradientHoverTarget::None, selected: GradientSelectedTarget::None, } } _ => self, } } fn update_hints(&self, responses: &mut VecDeque) { let hint_data = match self { 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) } }; hint_data.send_layout(responses); } fn update_cursor(&self, responses: &mut VecDeque) { responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::Default }); } } fn dismiss_color_stop_color_picker(tool_data: &mut GradientToolData, responses: &mut VecDeque) { if tool_data.color_picker_editing_color_stop.is_some() { if tool_data.color_picker_transaction_open { responses.add(DocumentMessage::EndTransaction); tool_data.color_picker_transaction_open = false; } tool_data.color_picker_editing_color_stop = None; } } 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, } } fn apply_gradient_update( data: &mut GradientToolData, context: &mut ToolActionMessageContext, responses: &mut VecDeque, condition: impl Fn(&Gradient) -> bool, update: impl Fn(&mut Gradient), ) { let selected_layers: Vec<_> = context .document .network_interface .selected_nodes() .selected_visible_layers(&context.document.network_interface) .collect(); let mut transaction_started = false; for layer in selected_layers { if NodeGraphLayer::is_raster_layer(layer, &mut context.document.network_interface) { continue; } if let Some(mut gradient) = get_gradient(layer, &context.document.network_interface) && condition(&gradient) { if !transaction_started { responses.add(DocumentMessage::StartTransaction); transaction_started = true; } update(&mut gradient); // Only check for the gradient table once we know we'll write back, since this is a graph traversal per layer // TODO: Drop the `Fill::Gradient` branch when all gradients become `Table` if get_gradient_table(layer, &context.document.network_interface).is_some() { dispatch_gradient_writes(layer, &gradient, responses); } else { responses.add(GraphOperationMessage::FillSet { layer, fill: Fill::Gradient(gradient), }); } } } if transaction_started { responses.add(DocumentMessage::EndTransaction); } if let Some(selected_gradient) = &mut data.selected_gradient && let Some(layer) = selected_gradient.layer && !NodeGraphLayer::is_raster_layer(layer, &mut context.document.network_interface) { update(&mut selected_gradient.gradient); } responses.add(PropertiesPanelMessage::Refresh); data.has_selected_gradient = has_gradient_on_selected_layers(context.document); responses.add(ToolMessage::RefreshToolOptions); } /// Set new gradient stops on every selected layer's gradient. Unlike `apply_gradient_update`, this doesn't open its own /// transaction so it can be called repeatedly during a color picker drag and have all the changes coalesced into a /// single undo entry by the surrounding 'on_commit' callback. fn apply_stops_update(data: &mut GradientToolData, context: &mut ToolActionMessageContext, responses: &mut VecDeque, stops: GradientStops) { let selected_layers: Vec<_> = context .document .network_interface .selected_nodes() .selected_visible_layers(&context.document.network_interface) .collect(); for layer in selected_layers { if NodeGraphLayer::is_raster_layer(layer, &mut context.document.network_interface) { continue; } if get_gradient_table(layer, &context.document.network_interface).is_some() { responses.add(GraphOperationMessage::GradientStopsSet { layer, stops: stops.clone() }); } else if let Some(mut gradient) = get_gradient(layer, &context.document.network_interface) { gradient.stops = stops.clone(); responses.add(GraphOperationMessage::FillSet { layer, fill: Fill::Gradient(gradient), }); } } if let Some(selected_gradient) = &mut data.selected_gradient { selected_gradient.gradient.stops = stops; } responses.add(PropertiesPanelMessage::Refresh); } /// Find the first selected visible layer that has a gradient and return both the layer ID and its resolved gradient. fn current_layer_and_gradient(document: &DocumentMessageHandler) -> (Option, Option) { for layer in document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface) { if let Some(gradient) = get_gradient(layer, &document.network_interface) { return (Some(layer), Some(gradient)); } } (None, None) } fn get_gradient_on_selected_layer(document: &DocumentMessageHandler) -> Option { document .network_interface .selected_nodes() .selected_visible_layers(&document.network_interface) .find_map(|layer| get_gradient(layer, &document.network_interface)) } fn has_gradient_on_selected_layers(document: &DocumentMessageHandler) -> bool { get_gradient_on_selected_layer(document).is_some() } #[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; use crate::messages::input_mapper::utility_types::input_mouse::ScrollDelta; use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; use crate::messages::portfolio::document::utility_types::misc::GroupFolderType; use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, OutputConnector}; pub use crate::test_utils::test_prelude::*; use glam::DAffine2; use graph_craft::document::value::TaggedValue; use graphene_std::ATTR_TRANSFORM; use graphene_std::table::{Table, TableRow}; use graphene_std::vector::style::{Fill, Gradient}; use graphene_std::vector::{GradientStop, GradientStops, fill}; use super::gradient_space_transform; async fn get_fills(editor: &mut EditorTestUtils) -> Vec<(Fill, DAffine2)> { let instrumented = match editor.eval_graph().await { Ok(instrumented) => instrumented, Err(e) => panic!("Failed to evaluate graph: {e}"), }; let document = editor.active_document(); let layers = document.metadata().all_layers(); layers .filter_map(|layer| { let fill = instrumented.grab_input_from_layer::>(layer, &document.network_interface, &editor.runtime)?; let transform = gradient_space_transform(layer, document); Some((fill, transform)) }) .collect() } async fn get_gradient(editor: &mut EditorTestUtils) -> (Gradient, DAffine2) { let fills = get_fills(editor).await; assert_eq!(fills.len(), 1, "Expected 1 gradient fill, found {}", fills.len()); let (fill, transform) = fills.first().unwrap(); let gradient = fill.as_gradient().expect("Expected gradient fill type"); (gradient.clone(), *transform) } fn assert_stops_at_positions(actual_positions: &[f64], expected_positions: &[f64], tolerance: f64) { assert_eq!( actual_positions.len(), expected_positions.len(), "Expected {} stops, found {}", expected_positions.len(), actual_positions.len() ); for (i, (actual, expected)) in actual_positions.iter().zip(expected_positions.iter()).enumerate() { assert!((actual - expected).abs() < tolerance, "Stop {i}: Expected position near {expected}, got {actual}"); } } async fn create_gradient_table_layer(editor: &mut EditorTestUtils) -> LayerNodeIdentifier { editor.drag_tool(ToolType::Rectangle, 0., 0., 100., 100., ModifierKeys::empty()).await; let document = editor.active_document(); let layer = document.metadata().all_layers().next().unwrap(); let gradient_node_id = editor.create_node_by_name(DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_value::IDENTIFIER)).await; editor .handle_message(NodeGraphMessage::CreateWire { output_connector: OutputConnector::node(gradient_node_id, 0), input_connector: InputConnector::node(layer.to_node(), 1), }) .await; editor .handle_message(NodeGraphMessage::SetInputValue { node_id: gradient_node_id, input_index: 1, value: TaggedValue::GradientTable(Table::new_from_row( TableRow::new_from_element(GradientStops::new([ GradientStop { position: 0., midpoint: 0.5, color: Color::RED, }, GradientStop { position: 1., midpoint: 0.5, color: Color::BLUE, }, ])) .with_attribute(ATTR_TRANSFORM, DAffine2::IDENTITY), )), }) .await; layer } #[tokio::test] async fn ignore_artboard() { let mut editor = EditorTestUtils::create(); editor.new_document().await; editor.drag_tool(ToolType::Artboard, 0., 0., 100., 100., ModifierKeys::empty()).await; editor.drag_tool(ToolType::Gradient, 2., 2., 4., 4., ModifierKeys::empty()).await; assert!(get_fills(&mut editor).await.is_empty()); } #[tokio::test] async fn ignore_raster() { let mut editor = EditorTestUtils::create(); editor.new_document().await; editor.create_raster_image(Image::new(100, 100, Color::WHITE), Some((0., 0.))).await; editor.drag_tool(ToolType::Gradient, 2., 2., 4., 4., ModifierKeys::empty()).await; assert!(get_fills(&mut editor).await.is_empty()); } #[tokio::test] async fn simple_draw() { let mut editor = EditorTestUtils::create(); editor.new_document().await; editor.drag_tool(ToolType::Rectangle, -5., -3., 100., 100., ModifierKeys::empty()).await; editor.select_primary_color(Color::GREEN).await; editor.select_secondary_color(Color::BLUE).await; editor.drag_tool(ToolType::Gradient, 2., 3., 24., 4., ModifierKeys::empty()).await; let (gradient, transform) = get_gradient(&mut editor).await; // Gradient goes from primary color to secondary color let stops = gradient.stops.iter().map(|stop| (stop.position, stop.color.to_rgba8_srgb())).collect::>(); assert_eq!(stops, vec![(0., Color::GREEN.to_rgba8_srgb()), (1., Color::BLUE.to_rgba8_srgb())]); assert!(transform.transform_point2(gradient.start).abs_diff_eq(DVec2::new(2., 3.), 1e-10)); assert!(transform.transform_point2(gradient.end).abs_diff_eq(DVec2::new(24., 4.), 1e-10)); } #[tokio::test] async fn snap_simple_draw() { let mut editor = EditorTestUtils::create(); editor.new_document().await; editor .handle_message(NavigationMessage::CanvasTiltSet { angle_radians: f64::consts::FRAC_PI_8, }) .await; let start = DVec2::new(0., 0.); let end = DVec2::new(24., 4.); editor.drag_tool(ToolType::Rectangle, -5., -3., 100., 100., ModifierKeys::empty()).await; editor.drag_tool(ToolType::Gradient, start.x, start.y, end.x, end.y, ModifierKeys::SHIFT).await; let (gradient, transform) = get_gradient(&mut editor).await; assert!(transform.transform_point2(gradient.start).abs_diff_eq(start, 1e-10)); // 15 degrees from horizontal let angle = f64::to_radians(15.); let direction = DVec2::new(angle.cos(), angle.sin()); let expected = start + direction * (end - start).length(); assert!(transform.transform_point2(gradient.end).abs_diff_eq(expected, 1e-10)); } #[tokio::test] async fn transformed_draw() { let mut editor = EditorTestUtils::create(); editor.new_document().await; editor .handle_message(NavigationMessage::CanvasTiltSet { angle_radians: f64::consts::FRAC_PI_8, }) .await; editor.drag_tool(ToolType::Rectangle, -5., -3., 100., 100., ModifierKeys::empty()).await; // Group rectangle let group_folder_type = GroupFolderType::Layer; editor.handle_message(DocumentMessage::GroupSelectedLayers { group_folder_type }).await; let metadata = editor.active_document().metadata(); let mut layers = metadata.all_layers(); let folder = layers.next().unwrap(); let rectangle = layers.next().unwrap(); assert_eq!(rectangle.parent(metadata), Some(folder)); // Transform the group editor .handle_message(GraphOperationMessage::TransformSet { layer: folder, transform: DAffine2::from_scale_angle_translation(DVec2::new(1., 2.), 0., -DVec2::X * 10.), transform_in: TransformIn::Local, skip_rerender: false, }) .await; editor.drag_tool(ToolType::Gradient, 2., 3., 24., 4., ModifierKeys::empty()).await; let (gradient, transform) = get_gradient(&mut editor).await; assert!(transform.transform_point2(gradient.start).abs_diff_eq(DVec2::new(2., 3.), 1e-10)); assert!(transform.transform_point2(gradient.end).abs_diff_eq(DVec2::new(24., 4.), 1e-10)); } #[tokio::test] async fn click_to_insert_stop() { let mut editor = EditorTestUtils::create(); editor.new_document().await; editor.drag_tool(ToolType::Rectangle, -5., -3., 100., 100., ModifierKeys::empty()).await; editor.select_primary_color(Color::GREEN).await; editor.select_secondary_color(Color::BLUE).await; editor.drag_tool(ToolType::Gradient, 0., 0., 100., 0., ModifierKeys::empty()).await; // Get initial gradient state (should have 2 stops) let (initial_gradient, _) = get_gradient(&mut editor).await; assert_eq!(initial_gradient.stops.len(), 2, "Expected 2 stops, found {}", initial_gradient.stops.len()); editor.select_tool(ToolType::Gradient).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; assert_eq!(updated_gradient.stops.len(), 3, "Expected 3 stops, found {}", updated_gradient.stops.len()); let positions: Vec = updated_gradient.stops.iter().map(|stop| stop.position).collect(); assert!( positions.iter().any(|pos| (pos - 0.25).abs() < 0.1), "Expected to find a stop near position 0.25, but found: {positions:?}" ); } #[tokio::test] async fn dragging_endpoint_sets_correct_point() { let mut editor = EditorTestUtils::create(); editor.new_document().await; editor.handle_message(NavigationMessage::CanvasZoomSet { zoom_factor: 2. }).await; editor.drag_tool(ToolType::Rectangle, -5., -3., 100., 100., ModifierKeys::empty()).await; let document = editor.active_document(); let selected_layer = document.network_interface.selected_nodes().selected_layers(document.metadata()).next().unwrap(); editor .handle_message(GraphOperationMessage::TransformSet { layer: selected_layer, transform: DAffine2::from_scale_angle_translation(DVec2::new(1.5, 0.8), 0.3, DVec2::new(10., -5.)), transform_in: TransformIn::Local, skip_rerender: false, }) .await; editor.select_primary_color(Color::GREEN).await; editor.select_secondary_color(Color::BLUE).await; editor.drag_tool(ToolType::Gradient, 0., 0., 100., 0., ModifierKeys::empty()).await; // Get the initial gradient state let (initial_gradient, transform) = get_gradient(&mut editor).await; assert_eq!(initial_gradient.stops.len(), 2, "Expected 2 stops, found {}", initial_gradient.stops.len()); // Verify initial gradient endpoints in viewport space let initial_start = transform.transform_point2(initial_gradient.start); let initial_end = transform.transform_point2(initial_gradient.end); assert!(initial_start.abs_diff_eq(DVec2::new(0., 0.), 1e-10)); assert!(initial_end.abs_diff_eq(DVec2::new(100., 0.), 1e-10)); editor.select_tool(ToolType::Gradient).await; // Simulate dragging the end point to a new position (100, 50) let start_pos = DVec2::new(100., 0.); let end_pos = DVec2::new(100., 50.); editor.move_mouse(start_pos.x, start_pos.y, ModifierKeys::empty(), MouseKeys::empty()).await; editor.left_mousedown(start_pos.x, start_pos.y, ModifierKeys::empty()).await; editor.move_mouse(end_pos.x, end_pos.y, ModifierKeys::empty(), MouseKeys::LEFT).await; editor .mouseup( EditorMouseState { editor_position: end_pos, mouse_keys: MouseKeys::empty(), scroll_delta: ScrollDelta::default(), }, ModifierKeys::empty(), ) .await; // Check the updated gradient let (updated_gradient, transform) = get_gradient(&mut editor).await; // Verify the start point hasn't changed let updated_start = transform.transform_point2(updated_gradient.start); assert!(updated_start.abs_diff_eq(DVec2::new(0., 0.), 1e-10)); // Verify the end point has been updated to the new position let updated_end = transform.transform_point2(updated_gradient.end); assert!(updated_end.abs_diff_eq(DVec2::new(100., 50.), 1e-10), "Expected end point at (100, 50), got {updated_end:?}"); } #[tokio::test] async fn dragging_stop_reorders_gradient() { let mut editor = EditorTestUtils::create(); editor.new_document().await; editor.drag_tool(ToolType::Rectangle, -5., -3., 100., 100., ModifierKeys::empty()).await; editor.select_primary_color(Color::GREEN).await; editor.select_secondary_color(Color::BLUE).await; editor.drag_tool(ToolType::Gradient, 0., 0., 100., 0., ModifierKeys::empty()).await; editor.select_tool(ToolType::Gradient).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()); // Verify initial stop positions and colors let mut stops = initial_gradient.stops.clone(); stops.sort(); let positions: Vec = stops.iter().map(|stop| stop.position).collect(); 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(25., 0.); editor .mousedown( EditorMouseState { editor_position: click_position, mouse_keys: MouseKeys::LEFT, scroll_delta: ScrollDelta::default(), }, ModifierKeys::empty(), ) .await; let drag_position = DVec2::new(80., 0.); editor.move_mouse(drag_position.x, drag_position.y, ModifierKeys::empty(), MouseKeys::LEFT).await; editor .mouseup( EditorMouseState { editor_position: drag_position, mouse_keys: MouseKeys::empty(), scroll_delta: ScrollDelta::default(), }, ModifierKeys::empty(), ) .await; let (updated_gradient, _) = get_gradient(&mut editor).await; assert_eq!(updated_gradient.stops.len(), 3, "Expected 3 stops after dragging, found {}", updated_gradient.stops.len()); // Verify updated stop positions and colors let mut updated_stops = updated_gradient.stops.clone(); updated_stops.sort(); // Check positions are now correctly ordered let updated_positions: Vec = updated_stops.iter().map(|stop| stop.position).collect(); assert_stops_at_positions(&updated_positions, &[0., 0.8, 1.], 0.1); // Colors should maintain their associations with the stop points assert_eq!(updated_stops.color[0].to_rgba8_srgb(), Color::GREEN.to_rgba8_srgb()); assert_eq!(updated_stops.color[1].to_rgba8_srgb(), middle_color); assert_eq!(updated_stops.color[2].to_rgba8_srgb(), Color::BLUE.to_rgba8_srgb()); } #[tokio::test] async fn select_and_delete_removes_stop() { let mut editor = EditorTestUtils::create(); editor.new_document().await; editor.drag_tool(ToolType::Rectangle, -5., -3., 100., 100., ModifierKeys::empty()).await; editor.select_primary_color(Color::GREEN).await; editor.select_secondary_color(Color::BLUE).await; editor.drag_tool(ToolType::Gradient, 0., 0., 100., 0., ModifierKeys::empty()).await; // Get initial gradient state (should have 2 stops) let (initial_gradient, _) = get_gradient(&mut editor).await; assert_eq!(initial_gradient.stops.len(), 2, "Expected 2 stops, found {}", initial_gradient.stops.len()); editor.select_tool(ToolType::Gradient).await; // Add two middle stops 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; editor.move_mouse(75., 0., ModifierKeys::empty(), MouseKeys::empty()).await; editor.left_mousedown(75., 0., ModifierKeys::empty()).await; editor.left_mouseup(75., 0., ModifierKeys::empty()).await; let (updated_gradient, _) = get_gradient(&mut editor).await; assert_eq!(updated_gradient.stops.len(), 4, "Expected 4 stops, found {}", updated_gradient.stops.len()); let positions: Vec = updated_gradient.stops.iter().map(|stop| stop.position).collect(); // Use helper function to verify positions assert_stops_at_positions(&positions, &[0., 0.25, 0.75, 1.], 0.05); // Select the stop at position 0.75 and delete it let position2 = DVec2::new(75., 0.); editor.move_mouse(position2.x, position2.y, ModifierKeys::empty(), MouseKeys::empty()).await; editor.left_mousedown(position2.x, position2.y, ModifierKeys::empty()).await; editor .mouseup( EditorMouseState { editor_position: position2, mouse_keys: MouseKeys::empty(), scroll_delta: ScrollDelta::default(), }, ModifierKeys::empty(), ) .await; editor.press(Key::Delete, ModifierKeys::empty()).await; // Verify we now have 3 stops let (final_gradient, _) = get_gradient(&mut editor).await; assert_eq!(final_gradient.stops.len(), 3, "Expected 3 stops after deletion, found {}", final_gradient.stops.len()); let final_positions: Vec = final_gradient.stops.iter().map(|stop| stop.position).collect(); // Verify final positions with helper function assert_stops_at_positions(&final_positions, &[0., 0.25, 1.], 0.05); // Additional verification that 0.75 stop is gone assert!(!final_positions.iter().any(|pos| (pos - 0.75).abs() < 0.05), "Stop at position 0.75 should have been deleted"); } #[tokio::test] async fn change_spread_method() { use graphene_std::vector::style::GradientSpreadMethod; let mut editor = EditorTestUtils::create(); editor.new_document().await; editor.drag_tool(ToolType::Rectangle, 0., 0., 100., 100., ModifierKeys::empty()).await; editor.drag_tool(ToolType::Gradient, 10., 10., 90., 90., ModifierKeys::empty()).await; // Verify default spread method is Pad let (gradient, _) = get_gradient(&mut editor).await; assert_eq!(gradient.spread_method, GradientSpreadMethod::Pad); // Update spread method to Repeat editor .handle_message(GradientToolMessage::UpdateOptions { options: GradientOptionsUpdate::SetSpreadMethod(GradientSpreadMethod::Repeat), }) .await; let (gradient, _) = get_gradient(&mut editor).await; assert_eq!(gradient.spread_method, GradientSpreadMethod::Repeat); // Update spread method to Reflect editor .handle_message(GradientToolMessage::UpdateOptions { options: GradientOptionsUpdate::SetSpreadMethod(GradientSpreadMethod::Reflect), }) .await; let (gradient, _) = get_gradient(&mut editor).await; assert_eq!(gradient.spread_method, GradientSpreadMethod::Reflect); } #[tokio::test] async fn gradient_table_drag_endpoint() { let mut editor = EditorTestUtils::create(); editor.new_document().await; let layer = create_gradient_table_layer(&mut editor).await; // Create original transform for the control geometry and apply it let initial_start = DVec2::new(10., 50.); let initial_end = DVec2::new(200., 50.); let stops = GradientStops::new([ GradientStop { position: 0., midpoint: 0.5, color: Color::RED, }, GradientStop { position: 1., midpoint: 0.5, color: Color::BLUE, }, ]); editor.handle_message(GraphOperationMessage::GradientStopsSet { layer, stops }).await; editor .handle_message(GraphOperationMessage::GradientLineSet { layer, start: initial_start, end: initial_end, }) .await; editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }).await; let document = editor.active_document(); let space_transform = gradient_space_transform(layer, document); let gradient = super::get_gradient(layer, &document.network_interface).unwrap(); let viewport_start = space_transform.transform_point2(gradient.start); let viewport_end = space_transform.transform_point2(gradient.end); // Drag target of the end point, move 80px down let new_viewport_end = viewport_end + DVec2::new(0., 80.); editor.select_tool(ToolType::Gradient).await; editor.move_mouse(viewport_end.x, viewport_end.y, ModifierKeys::empty(), MouseKeys::empty()).await; editor.left_mousedown(viewport_end.x, viewport_end.y, ModifierKeys::empty()).await; editor.move_mouse(new_viewport_end.x, new_viewport_end.y, ModifierKeys::empty(), MouseKeys::LEFT).await; editor .mouseup( EditorMouseState { editor_position: new_viewport_end, mouse_keys: MouseKeys::empty(), scroll_delta: ScrollDelta::default(), }, ModifierKeys::empty(), ) .await; // Verify if the gradient position is updated correctly let document = editor.active_document(); let updated = super::get_gradient(layer, &document.network_interface).expect("Gradient should exist after drag"); let updated_space_transform = gradient_space_transform(layer, document); let updated_viewport_start = updated_space_transform.transform_point2(updated.start); let updated_viewport_end = updated_space_transform.transform_point2(updated.end); assert!( updated_viewport_start.abs_diff_eq(viewport_start, 1.), "Start should not move. Expected {viewport_start:?}, got {updated_viewport_start:?}" ); assert!( updated_viewport_end.abs_diff_eq(new_viewport_end, 1.), "End should move to new position. Expected {new_viewport_end:?}, got {updated_viewport_end:?}" ); } #[tokio::test] async fn gradient_table_preserves_stops() { let mut editor = EditorTestUtils::create(); editor.new_document().await; let layer = create_gradient_table_layer(&mut editor).await; // Set up a 3-stop gradient with distinct colors let original_stops = GradientStops::new([ GradientStop { position: 0., midpoint: 0.5, color: Color::RED, }, GradientStop { position: 0.5, midpoint: 0.5, color: Color::GREEN, }, GradientStop { position: 1., midpoint: 0.5, color: Color::BLUE, }, ]); let initial_start = DVec2::new(10., 50.); let initial_end = DVec2::new(200., 50.); editor.handle_message(GraphOperationMessage::GradientStopsSet { layer, stops: original_stops.clone() }).await; editor .handle_message(GraphOperationMessage::GradientLineSet { layer, start: initial_start, end: initial_end, }) .await; editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }).await; let document = editor.active_document(); let space_transform = gradient_space_transform(layer, document); let gradient = super::get_gradient(layer, &document.network_interface).unwrap(); let viewport_end = space_transform.transform_point2(gradient.end); // Drag the end point 80px down let new_viewport_end = viewport_end + DVec2::new(0., 80.); editor.select_tool(ToolType::Gradient).await; editor.move_mouse(viewport_end.x, viewport_end.y, ModifierKeys::empty(), MouseKeys::empty()).await; editor.left_mousedown(viewport_end.x, viewport_end.y, ModifierKeys::empty()).await; editor.move_mouse(new_viewport_end.x, new_viewport_end.y, ModifierKeys::empty(), MouseKeys::LEFT).await; editor .mouseup( EditorMouseState { editor_position: new_viewport_end, mouse_keys: MouseKeys::empty(), scroll_delta: ScrollDelta::default(), }, ModifierKeys::empty(), ) .await; // Verify stops are preserved after dragging let document = editor.active_document(); let updated = super::get_gradient(layer, &document.network_interface).expect("Gradient should exist after drag"); assert_eq!(updated.stops.len(), 3, "Stop count should be preserved"); assert_stops_at_positions(&updated.stops.position, &[0., 0.5, 1.], 1e-10); assert_eq!(updated.stops.color[0].to_rgba8_srgb(), Color::RED.to_rgba8_srgb(), "First stop color should be preserved"); assert_eq!(updated.stops.color[1].to_rgba8_srgb(), Color::GREEN.to_rgba8_srgb(), "Middle stop color should be preserved"); assert_eq!(updated.stops.color[2].to_rgba8_srgb(), Color::BLUE.to_rgba8_srgb(), "Last stop color should be preserved"); } }