Add support for setting the spread method for gradient fills (#3953)

* Add spread method support for gradients

* Add GradientSpreadMethod enum (Pad, Repeat, Reflect) to vector-types

* Add radio buttons to gradient tool and fill properties panel

* Convert spread method when importing SVGs via usvg

* Sync backup gradient input when changing spread method

* Table<GradientStops> rendering is not yet updated for spread method

* Sync gradient tool options with layer's gradient

* Sync gradient_type and spread_method from the selected
  layer's existing gradient to the tool options bar when
  switching to the gradient tool

* Refactor has_gradient_on_selected_layers
  to reuse a new get_gradient_on_selected_layer helper

* Swap Reflect and Repeat order in UI radio buttons

* Fix alignment of the radio buttons in right panel

* Fix the position of the radio buttons in the tool

* Rename SpreadMethod to SetSpreadMethod

* Move default spread method omission logic
This commit is contained in:
YohYamasaki
2026-04-14 08:09:57 +00:00
committed by GitHub
parent da45ab2f87
commit 79d778a535
6 changed files with 232 additions and 14 deletions
@@ -15,7 +15,7 @@ use graphene_std::renderer::Quad;
use graphene_std::renderer::convert_usvg_path::convert_usvg_path;
use graphene_std::table::Table;
use graphene_std::text::{Font, TypesettingConfig};
use graphene_std::vector::style::{Fill, Gradient, GradientStop, GradientStops, GradientType, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
use graphene_std::vector::style::{Fill, Gradient, GradientSpreadMethod, GradientStop, GradientStops, GradientType, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
#[derive(ExtractField)]
pub struct GraphOperationMessageContext<'a> {
@@ -765,6 +765,14 @@ fn apply_usvg_stroke(stroke: &usvg::Stroke, modify_inputs: &mut ModifyInputsCont
}
}
fn convert_spread_method(spread_method: usvg::SpreadMethod) -> GradientSpreadMethod {
match spread_method {
usvg::SpreadMethod::Pad => GradientSpreadMethod::Pad,
usvg::SpreadMethod::Reflect => GradientSpreadMethod::Reflect,
usvg::SpreadMethod::Repeat => GradientSpreadMethod::Repeat,
}
}
fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, bounds_transform: DAffine2, graphite_gradient_stops: &HashMap<String, GradientStops>) {
modify_inputs.fill_set(match &fill.paint() {
usvg::Paint::Color(color) => Fill::solid(usvg_color(*color, fill.opacity().get())),
@@ -787,8 +795,15 @@ fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, b
GradientStops::new(stops)
}
};
let spread_method = convert_spread_method(linear.spread_method());
Fill::Gradient(Gradient { start, end, gradient_type, stops })
Fill::Gradient(Gradient {
start,
end,
gradient_type,
stops,
spread_method,
})
}
usvg::Paint::RadialGradient(radial) => {
let gradient_transform = usvg_transform(radial.transform());
@@ -810,8 +825,15 @@ fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, b
GradientStops::new(stops)
}
};
let spread_method = convert_spread_method(radial.spread_method());
Fill::Gradient(Gradient { start, end, gradient_type, stops })
Fill::Gradient(Gradient {
start,
end,
gradient_type,
stops,
spread_method,
})
}
usvg::Paint::Pattern(_) => {
warn!("SVG patterns are not currently supported");
@@ -27,7 +27,7 @@ use graphene_std::transform::{Footprint, ReferencePoint, ScaleType, Transform};
use graphene_std::vector::QRCodeErrorCorrectionLevel;
use graphene_std::vector::misc::BooleanOperation;
use graphene_std::vector::misc::{ArcType, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType};
use graphene_std::vector::style::{Fill, FillChoice, FillType, GradientStops, GradientType, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin};
use graphene_std::vector::style::{Fill, FillChoice, FillType, GradientSpreadMethod, GradientStops, GradientType, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin};
pub(crate) fn string_properties(text: &str) -> Vec<LayoutGroup> {
let widget = TextLabel::new(text).widget_instance();
@@ -2006,6 +2006,55 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte
]);
widgets.push(LayoutGroup::row(row));
let mut spread_methods_row: Vec<WidgetInstance> = vec![TextLabel::new("").widget_instance(), Separator::new(SeparatorStyle::Unrelated).widget_instance()];
let spread_method_entries = [GradientSpreadMethod::Pad, GradientSpreadMethod::Reflect, GradientSpreadMethod::Repeat]
.iter()
.map(|&spread_method| {
let gradient_for_input = gradient_for_closure.clone();
let gradient_for_backup = gradient_for_closure.clone();
let set_input_value = update_value(
move |_: &()| {
let mut new_gradient = gradient_for_input.clone();
new_gradient.spread_method = spread_method;
TaggedValue::Fill(Fill::Gradient(new_gradient))
},
node_id,
FillInput::<Color>::INDEX,
);
let set_backup_value = update_value(
move |_: &()| {
let mut new_gradient = gradient_for_backup.clone();
new_gradient.spread_method = spread_method;
TaggedValue::Gradient(new_gradient)
},
node_id,
BackupGradientInput::INDEX,
);
RadioEntryData::new(format!("{:?}", spread_method))
.label(format!("{:?}", spread_method))
.on_update(move |_| Message::Batched {
messages: Box::new([
set_input_value(&()),
set_backup_value(&()),
GradientToolMessage::UpdateOptions {
options: GradientOptionsUpdate::SetSpreadMethod(spread_method),
}
.into(),
]),
})
.on_commit(commit_value)
})
.collect();
add_blank_assist(&mut spread_methods_row);
spread_methods_row.extend_from_slice(&[RadioInput::new(spread_method_entries).selected_index(Some(gradient.spread_method as u32)).widget_instance()]);
widgets.push(LayoutGroup::row(spread_methods_row));
}
widgets
@@ -9,7 +9,7 @@ use crate::messages::tool::common_functionality::auto_panning::AutoPanning;
use crate::messages::tool::common_functionality::graph_modification_utils::{NodeGraphLayer, get_gradient};
use crate::messages::tool::common_functionality::snapping::{SnapCandidatePoint, SnapConstraint, SnapData, SnapManager, SnapTypeConfiguration};
use graphene_std::raster::color::Color;
use graphene_std::vector::style::{Fill, Gradient, GradientStops, GradientType};
use graphene_std::vector::style::{Fill, Gradient, GradientSpreadMethod, GradientStops, GradientType};
#[derive(Default, ExtractField)]
pub struct GradientTool {
@@ -21,6 +21,7 @@ pub struct GradientTool {
#[derive(Default)]
pub struct GradientOptions {
gradient_type: GradientType,
spread_method: GradientSpreadMethod,
}
#[impl_message(Message, ToolMessage, Gradient)]
@@ -53,6 +54,7 @@ pub enum GradientOptionsUpdate {
Type(GradientType),
ReverseStops,
ReverseDirection,
SetSpreadMethod(GradientSpreadMethod),
}
impl ToolMetadata for GradientTool {
@@ -84,6 +86,10 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for Grad
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 {
@@ -123,6 +129,22 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for Grad
self.data.has_selected_gradient = has_gradient;
responses.add(ToolMessage::RefreshToolOptions);
}
// Sync tool options with the selected layer's gradient
if has_gradient && let Some(gradient) = get_gradient_on_selected_layer(&context.document) {
let type_differs = self.options.gradient_type != gradient.gradient_type;
let spread_method_differs = self.options.spread_method != gradient.spread_method;
if type_differs {
self.options.gradient_type = gradient.gradient_type;
}
if spread_method_differs {
self.options.spread_method = gradient.spread_method;
}
if type_differs || spread_method_differs {
responses.add(ToolMessage::RefreshToolOptions);
}
};
}
}
}
@@ -168,7 +190,36 @@ impl LayoutHolder for GradientTool {
})
.widget_instance();
let mut widgets = vec![gradient_type, Separator::new(SeparatorStyle::Unrelated).widget_instance(), reverse_stops];
let spread_method = RadioInput::new(vec![
RadioEntryData::new("Pad").label("Pad").tooltip_label("Pad").on_update(move |_| {
GradientToolMessage::UpdateOptions {
options: GradientOptionsUpdate::SetSpreadMethod(GradientSpreadMethod::Pad),
}
.into()
}),
RadioEntryData::new("Reflect").label("Reflect").tooltip_label("Reflect").on_update(move |_| {
GradientToolMessage::UpdateOptions {
options: GradientOptionsUpdate::SetSpreadMethod(GradientSpreadMethod::Reflect),
}
.into()
}),
RadioEntryData::new("Repeat").label("Repeat").tooltip_label("Repeat").on_update(move |_| {
GradientToolMessage::UpdateOptions {
options: GradientOptionsUpdate::SetSpreadMethod(GradientSpreadMethod::Repeat),
}
.into()
}),
])
.selected_index(Some(self.options.spread_method as u32))
.widget_instance();
let mut widgets = vec![
gradient_type,
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
spread_method,
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
reverse_stops,
];
if self.options.gradient_type == GradientType::Radial {
let orientation = self
@@ -1149,7 +1200,14 @@ impl Fsm for GradientToolFsmState {
gradient.clone()
} else {
// Generate a new gradient
Gradient::new(DVec2::ZERO, global_tool_data.secondary_color, DVec2::ONE, global_tool_data.primary_color, tool_options.gradient_type)
Gradient::new(
DVec2::ZERO,
global_tool_data.secondary_color,
DVec2::ONE,
global_tool_data.primary_color,
tool_options.gradient_type,
tool_options.spread_method,
)
};
let mut selected_gradient = SelectedGradient::new(gradient, layer, document);
selected_gradient.dragging = GradientDragTarget::New;
@@ -1501,12 +1559,16 @@ fn apply_gradient_update(
responses.add(ToolMessage::RefreshToolOptions);
}
fn has_gradient_on_selected_layers(document: &DocumentMessageHandler) -> bool {
fn get_gradient_on_selected_layer(document: &DocumentMessageHandler) -> Option<Gradient> {
document
.network_interface
.selected_nodes()
.selected_visible_layers(&document.network_interface)
.any(|layer| get_gradient(layer, &document.network_interface).is_some())
.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)]
@@ -1941,4 +2003,38 @@ mod test_gradient {
// 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);
}
}