diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 68b79a8d7f..19a4c85ad1 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -221,7 +221,7 @@ impl MessageHandler> for DocumentMes // Send the overlays message to the overlays message handler self.overlays_message_handler - .process_message(message, responses, OverlaysMessageContext { visibility_settings, viewport }); + .process_message(message, responses, OverlaysMessageContext { visibility_settings, viewport, animation_time: ipp.time as f64 }); } DocumentMessage::PropertiesPanel(message) => { let context = PropertiesPanelMessageContext { diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index 03fc166dd4..7be44a94ea 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -665,6 +665,8 @@ fn apply_usvg_stroke(stroke: &usvg::Stroke, modify_inputs: &mut ModifyInputsCont if let usvg::Paint::Color(color) = &stroke.paint() { modify_inputs.stroke_set(Stroke { color: Some(usvg_color(*color, stroke.opacity().get())), + //Added the gradient field to the Stroke struct + gradient: None, weight: stroke.width().get() as f64, dash_lengths: stroke.dasharray().as_ref().map(|lengths| lengths.iter().map(|&length| length as f64).collect()).unwrap_or_default(), dash_offset: stroke.dashoffset() as f64, diff --git a/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs b/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs index 3bf436251c..52831db81a 100644 --- a/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs +++ b/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs @@ -5,6 +5,8 @@ use crate::messages::prelude::*; pub struct OverlaysMessageContext<'a> { pub visibility_settings: OverlaysVisibilitySettings, pub viewport: &'a ViewportMessageHandler, + /// Current time in milliseconds passed from the input preprocessor, used to drive overlay animations (e.g. marching ants). + pub animation_time: f64, } #[derive(Debug, Clone, Default, ExtractField)] @@ -19,7 +21,7 @@ pub struct OverlaysMessageHandler { #[message_handler_data] impl MessageHandler> for OverlaysMessageHandler { fn process_message(&mut self, message: OverlaysMessage, responses: &mut VecDeque, context: OverlaysMessageContext) { - let OverlaysMessageContext { visibility_settings, viewport, .. } = context; + let OverlaysMessageContext { visibility_settings, viewport, animation_time } = context; match message { #[cfg(target_family = "wasm")] @@ -55,6 +57,7 @@ impl MessageHandler> for OverlaysMes render_context: canvas_context.clone(), visibility_settings: visibility_settings.clone(), viewport: *viewport, + animation_time, }, }); for provider in &self.overlay_providers { @@ -62,6 +65,7 @@ impl MessageHandler> for OverlaysMes render_context: canvas_context.clone(), visibility_settings: visibility_settings.clone(), viewport: *viewport, + animation_time, })); } } @@ -70,7 +74,7 @@ impl MessageHandler> for OverlaysMes OverlaysMessage::Draw => { use super::utility_types::OverlayContext; - let overlay_context = OverlayContext::new(*viewport, visibility_settings); + let overlay_context = OverlayContext::new(*viewport, visibility_settings, animation_time); if visibility_settings.all() { responses.add(DocumentMessage::GridOverlays { context: overlay_context.clone() }); @@ -83,7 +87,7 @@ impl MessageHandler> for OverlaysMes } #[cfg(all(not(target_family = "wasm"), test))] OverlaysMessage::Draw => { - let _ = (responses, visibility_settings, viewport); + let _ = (responses, visibility_settings, viewport, animation_time); } OverlaysMessage::AddProvider { provider: message } => { self.overlay_providers.insert(message); diff --git a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs index 9991d39c57..e0be90f8e4 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs @@ -170,6 +170,8 @@ pub struct OverlayContext { internal: Arc>, pub viewport: ViewportMessageHandler, pub visibility_settings: OverlaysVisibilitySettings, + /// Current time in milliseconds, used to animate effects like marching ants. + pub animation_time: f64, } impl Clone for OverlayContext { @@ -181,6 +183,7 @@ impl Clone for OverlayContext { internal: self.internal.clone(), viewport: self.viewport, visibility_settings, + animation_time: self.animation_time, } } } @@ -198,6 +201,7 @@ impl std::fmt::Debug for OverlayContext { .field("scene", &"Scene { ... }") .field("viewport", &self.viewport) .field("visibility_settings", &self.visibility_settings) + .field("animation_time", &self.animation_time) .finish() } } @@ -209,6 +213,7 @@ impl Default for OverlayContext { internal: Mutex::new(OverlayContextInternal::default()).into(), viewport: ViewportMessageHandler::default(), visibility_settings: OverlaysVisibilitySettings::default(), + animation_time: 0., } } } @@ -220,7 +225,7 @@ impl core::hash::Hash for OverlayContext { impl OverlayContext { #[allow(dead_code)] - pub(super) fn new(viewport: ViewportMessageHandler, visibility_settings: OverlaysVisibilitySettings) -> Self { + pub(super) fn new(viewport: ViewportMessageHandler, visibility_settings: OverlaysVisibilitySettings, animation_time: f64) -> Self { Self { internal: Arc::new(Mutex::new(OverlayContextInternal::new(viewport, visibility_settings))), viewport, diff --git a/editor/src/messages/portfolio/document/overlays/utility_types_web.rs b/editor/src/messages/portfolio/document/overlays/utility_types_web.rs index c03ba387d3..8518881fdc 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_web.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_web.rs @@ -160,6 +160,8 @@ pub struct OverlayContext { pub render_context: web_sys::CanvasRenderingContext2d, pub viewport: ViewportMessageHandler, pub visibility_settings: OverlaysVisibilitySettings, + /// Current time in milliseconds (e.g. from `js_sys::Date::now()`), used to animate effects like marching ants. + pub animation_time: f64, } // Message hashing isn't used but is required by the message system macros impl core::hash::Hash for OverlayContext { diff --git a/editor/src/messages/tool/tool_messages/select_tool.rs b/editor/src/messages/tool/tool_messages/select_tool.rs index b8c6751565..7a7dd07bf2 100644 --- a/editor/src/messages/tool/tool_messages/select_tool.rs +++ b/editor/src/messages/tool/tool_messages/select_tool.rs @@ -406,6 +406,8 @@ struct SelectToolData { snap_candidates: Vec, auto_panning: AutoPanning, drag_start_center: ViewportPosition, + /// Whether the tool is currently subscribed to animation frame events to drive the marching ants animation. + marching_ants_subscribed: bool, } impl SelectToolData { @@ -421,6 +423,27 @@ impl SelectToolData { } } } +/// Subscribe to per-frame animation ticks so the marching ants selection border animates continuously. + fn start_marching_ants(&mut self, responses: &mut VecDeque) { + if !self.marching_ants_subscribed { + self.marching_ants_subscribed = true; + responses.add(BroadcastMessage::SubscribeEvent { + on: EventMessage::AnimationFrame, + send: Box::new(OverlaysMessage::Draw.into()), + }); + } + } + + /// Unsubscribe from per-frame animation ticks when the selection box is no longer being drawn. + fn stop_marching_ants(&mut self, responses: &mut VecDeque) { + if self.marching_ants_subscribed { + self.marching_ants_subscribed = false; + responses.add(BroadcastMessage::UnsubscribeEvent { + on: EventMessage::AnimationFrame, + send: Box::new(OverlaysMessage::Draw.into()), + }); + } + } pub fn selection_quad(&self) -> Quad { let bbox = self.selection_box(); @@ -965,10 +988,17 @@ impl Fsm for SelectToolFsmState { let fill_color = Some(COLOR_OVERLAY_BLUE_05); let polygon = &tool_data.lasso_polygon; + // Animate the dash offset to produce the "marching ants" effect. The dash pattern repeats every 8 px (4 px dash + 4 px gap), + // so wrapping the time to [0, 8) via the modulo gives a smooth, continuously looping animation. + // MARCHING_ANTS_PIXELS_PER_SECOND controls how fast the dashes march around the selection border. + const MARCHING_ANTS_PIXELS_PER_SECOND: f64 = 100.; // How many pixels the pattern advances per second + const MARCHING_ANTS_PERIOD: f64 = 8.; // One full cycle = dash length (4 px) + gap length (4 px) + let marching_ants_offset = (overlay_context.animation_time / 1000. * MARCHING_ANTS_PIXELS_PER_SECOND) % MARCHING_ANTS_PERIOD; + match (selection_shape, current_selection_mode) { - (SelectionShapeType::Box, SelectionMode::Enclosed) => overlay_context.dashed_quad(quad, None, fill_color, Some(4.), Some(4.), Some(0.5)), - (SelectionShapeType::Lasso, SelectionMode::Enclosed) => overlay_context.dashed_polygon(polygon, None, fill_color, Some(4.), Some(4.), Some(0.5)), + (SelectionShapeType::Box, SelectionMode::Enclosed) => overlay_context.dashed_quad(quad, None, fill_color, Some(4.), Some(4.), Some(marching_ants_offset)), + (SelectionShapeType::Lasso, SelectionMode::Enclosed) => overlay_context.dashed_polygon(polygon, None, fill_color, Some(4.), Some(4.), Some(marching_ants_offset)), (SelectionShapeType::Box, _) => overlay_context.quad(quad, None, fill_color), (SelectionShapeType::Lasso, _) => overlay_context.polygon(polygon, None, fill_color), } @@ -1125,6 +1155,8 @@ impl Fsm for SelectToolFsmState { } } else { let selection_shape = if input.keyboard.key(lasso_select) { SelectionShapeType::Lasso } else { SelectionShapeType::Box }; + // Subscribe to animation frames so the marching ants selection border animates continuously. + tool_data.start_marching_ants(responses); SelectToolFsmState::Drawing { selection_shape, has_drawn: false } } }; @@ -1556,7 +1588,8 @@ impl Fsm for SelectToolFsmState { } tool_data.lasso_polygon.clear(); - + // Unsubscribe from animation frames now that the selection box is finalized. + tool_data.stop_marching_ants(responses); responses.add(OverlaysMessage::Draw); let selection = tool_data.nested_selection_behavior; @@ -1603,6 +1636,8 @@ impl Fsm for SelectToolFsmState { responses.add(DocumentMessage::AbortTransaction); tool_data.snap_manager.cleanup(responses); tool_data.lasso_polygon.clear(); + // Unsubscribe from marching ants animation in case we were in Drawing state. + tool_data.stop_marching_ants(responses); responses.add(OverlaysMessage::Draw); let selection = tool_data.nested_selection_behavior; diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index d7736f804b..2de73ddc41 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -107,10 +107,23 @@ impl RenderExt for Stroke { render_params: &RenderParams, ) -> Self::Output { // Don't render a stroke at all if it would be invisible - let Some(color) = self.color else { return String::new() }; if !self.has_renderable_stroke() { return String::new(); } + let paint = match (&self.gradient, self.color) { + (Some(gradient), _) => { + let gradient_id = gradient.render(_svg_defs, _element_transform, _stroke_transform, _bounds, _transformed_bounds, render_params); + format!(r##" stroke="url('#{gradient_id}')""##) + } + (_, Some(color)) => { + let mut result = format!(r##" stroke="#{}""##, color.to_rgb_hex_srgb_from_gamma()); + if color.a() < 1. { + let _ = write!(result, r#" stroke-opacity="{}""#, (color.a() * 1000.).round() / 1000.); + } + result + } + _ => return String::new(), + }; let default_weight = if self.align != StrokeAlign::Center && render_params.aligned_strokes { 1. / 2. } else { 1. }; @@ -125,10 +138,7 @@ impl RenderExt for Stroke { let paint_order = (self.paint_order != PaintOrder::StrokeAbove || render_params.override_paint_order).then_some(PaintOrder::StrokeBelow); // Render the needed stroke attributes - let mut attributes = format!(r##" stroke="#{}""##, color.to_rgb_hex_srgb_from_gamma()); - if color.a() < 1. { - let _ = write!(&mut attributes, r#" stroke-opacity="{}""#, (color.a() * 1000.).round() / 1000.); - } + let mut attributes = paint; if let Some(mut weight) = weight { if stroke_align.is_some() && render_params.aligned_strokes { weight *= 2.; diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index c589e6177d..f9a42d03e3 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1115,34 +1115,90 @@ impl Render for Table { }; let do_stroke = |scene: &mut Scene, width_scale: f64| { - if let Some(stroke) = row.element.style.stroke() { - let color = match stroke.color { - Some(color) => peniko::Color::new([color.r(), color.g(), color.b(), color.a()]), - None => peniko::Color::TRANSPARENT, - }; - let cap = match stroke.cap { + if let Some(stroke_style) = row.element.style.stroke() { + let cap = match stroke_style.cap { StrokeCap::Butt => Cap::Butt, StrokeCap::Round => Cap::Round, StrokeCap::Square => Cap::Square, }; - let join = match stroke.join { + let join = match stroke_style.join { StrokeJoin::Miter => Join::Miter, StrokeJoin::Bevel => Join::Bevel, StrokeJoin::Round => Join::Round, }; - let dash_pattern = stroke.dash_lengths.iter().map(|l| l.max(0.)).collect(); - let stroke = kurbo::Stroke { - width: stroke.weight * width_scale, - miter_limit: stroke.join_miter_limit, + let dash_pattern = stroke_style.dash_lengths.iter().map(|l| l.max(0.)).collect(); + let kurbo_stroke = kurbo::Stroke { + width: stroke_style.weight * width_scale, + miter_limit: stroke_style.join_miter_limit, join, start_cap: cap, end_cap: cap, dash_pattern, - dash_offset: stroke.dash_offset, + dash_offset: stroke_style.dash_offset, }; - if stroke.width > 0. { - scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), color, None, &path); + if kurbo_stroke.width > 0. { + let (brush, brush_transform) = if let Some(gradient) = stroke_style.gradient.as_ref() { + let mut stops = peniko::ColorStops::new(); + for (position, color, _) in gradient.stops.interpolated_samples() { + stops.push(peniko::ColorStop { + offset: position as f32, + color: peniko::color::DynamicColor::from_alpha_color(peniko::Color::new([color.r(), color.g(), color.b(), color.a()])), + }); + } + + let bounds = row.element.nonzero_bounding_box(); + let bound_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]); + + let inverse_parent_transform = if parent_transform.matrix2.determinant() != 0. { + parent_transform.inverse() + } else { + Default::default() + }; + let mod_points = inverse_parent_transform * multiplied_transform * bound_transform; + + let start = mod_points.transform_point2(gradient.start); + let end = mod_points.transform_point2(gradient.end); + + let brush = peniko::Brush::Gradient(peniko::Gradient { + kind: match gradient.gradient_type { + GradientType::Linear => peniko::LinearGradientPosition { + start: to_point(start), + end: to_point(end), + } + .into(), + GradientType::Radial => { + let radius = start.distance(end); + peniko::RadialGradientPosition { + start_center: to_point(start), + start_radius: 0., + end_center: to_point(start), + end_radius: radius as f32, + } + .into() + } + }, + stops, + interpolation_alpha_space: peniko::InterpolationAlphaSpace::Premultiplied, + ..Default::default() + }); + let inverse_element_transform = if element_transform.matrix2.determinant() != 0. { + element_transform.inverse() + } else { + Default::default() + }; + let brush_transform = kurbo::Affine::new((inverse_element_transform * parent_transform).to_cols_array()); + + (brush, Some(brush_transform)) + } else { + let color = stroke_style + .color + .map(|color| peniko::Color::new([color.r(), color.g(), color.b(), color.a()])) + .unwrap_or(peniko::Color::TRANSPARENT); + (peniko::Brush::Solid(color), None) + }; + + scene.stroke(&kurbo_stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, brush_transform, &path); } } }; diff --git a/node-graph/libraries/vector-types/src/vector/style.rs b/node-graph/libraries/vector-types/src/vector/style.rs index 0828c4e6f2..ce096e7ad1 100644 --- a/node-graph/libraries/vector-types/src/vector/style.rs +++ b/node-graph/libraries/vector-types/src/vector/style.rs @@ -304,6 +304,8 @@ fn daffine2_identity() -> DAffine2 { pub struct Stroke { /// Stroke color pub color: Option, + /// Optional gradient paint. If set, overrides `color`. + pub gradient: Option, /// Line thickness pub weight: f64, pub dash_lengths: Vec, @@ -325,6 +327,7 @@ pub struct Stroke { impl std::hash::Hash for Stroke { fn hash(&self, state: &mut H) { self.color.hash(state); + self.gradient.hash(state); self.weight.to_bits().hash(state); { self.dash_lengths.len().hash(state); @@ -344,6 +347,7 @@ impl Stroke { pub const fn new(color: Option, weight: f64) -> Self { Self { color, + gradient: None, weight, dash_lengths: Vec::new(), dash_offset: 0., @@ -359,6 +363,12 @@ impl Stroke { pub fn lerp(&self, other: &Self, time: f64) -> Self { Self { color: self.color.map(|color| color.lerp(&other.color.unwrap_or(color), time as f32)), + gradient: match (&self.gradient, &other.gradient) { + (Some(a), Some(b)) => Some(a.lerp(b, time)), + (Some(a), None) if time < 0.5 => Some(a.clone()), + (None, Some(b)) if time >= 0.5 => Some(b.clone()), + _ => None, + }, weight: self.weight + (other.weight - self.weight) * time, dash_lengths: self.dash_lengths.iter().zip(other.dash_lengths.iter()).map(|(a, b)| a + (b - a) * time).collect(), dash_offset: self.dash_offset + (other.dash_offset - self.dash_offset) * time, @@ -398,6 +408,10 @@ impl Stroke { pub fn color(&self) -> Option { self.color } + /// Get the current stroke gradient. + pub fn gradient(&self) -> Option<&Gradient> { + self.gradient.as_ref() + } /// Get the current stroke weight. pub fn weight(&self) -> f64 { @@ -440,9 +454,20 @@ impl Stroke { pub fn with_color(mut self, color: &Option) -> Option { self.color = *color; + if color.is_some() { + self.gradient = None; + } Some(self) } + /// Set the stroke's gradient, replacing the color if necessary. + pub fn with_gradient(mut self, gradient: Option) -> Self { + self.gradient = gradient; + if self.gradient.is_some() { + self.color = None; + } + self + } pub fn with_weight(mut self, weight: f64) -> Self { self.weight = weight; @@ -488,7 +513,14 @@ impl Stroke { } pub fn has_renderable_stroke(&self) -> bool { - self.weight > 0. && self.color.is_some_and(|color| color.a() != 0.) + if self.weight <= 0. { + return false; + } + + let has_color_alpha = self.color.is_some_and(|color| color.a() != 0.); + let has_gradient_alpha = self.gradient.as_ref().is_some_and(|gradient| gradient.stops.color.iter().any(|color| color.a() != 0.)); + + has_color_alpha || has_gradient_alpha } } @@ -498,6 +530,7 @@ impl Default for Stroke { Self { weight: 0., color: Some(Color::from_rgba8_srgb(0, 0, 0, 255)), + gradient: None, dash_lengths: Vec::new(), dash_offset: 0., cap: StrokeCap::Butt, @@ -530,7 +563,14 @@ impl std::fmt::Display for PathStyle { let fill = &self.fill; let stroke = match &self.stroke { - Some(stroke) => format!("#{} (Weight: {} px)", stroke.color.map_or("None".to_string(), |c| c.to_rgba_hex_srgb()), stroke.weight), + Some(stroke) => { + let paint = match (&stroke.gradient, stroke.color) { + (Some(_), _) => "Gradient".to_string(), + (_, Some(color)) => format!("#{}", color.to_rgba_hex_srgb()), + _ => "None".to_string(), + }; + format!("{paint} (Weight: {} px)", stroke.weight) + } None => "None".to_string(), }; diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index f33d1a17fc..aea254524b 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -204,6 +204,7 @@ where { let stroke = Stroke { color: color.into(), + gradient: None, weight, dash_lengths: dash_lengths.into_vec(), dash_offset,