Move the gradient spread method into GradientRamp and the color picker popover (#4402)

* Add a spread method field to GradientRamp, carried at runtime as the gradient item's attribute

* Retire the Fill node's spread method input, folding its value into the gradient ramps on document upgrade

* Add an Ends spread method radio to the color picker popover, replacing the Gradient tool's control bar radio

* Update the demo art
This commit is contained in:
Keavon Chambers
2026-08-04 04:08:21 -07:00
committed by Dennis Kobert
parent 2196d308a7
commit 26d67eb48b
19 changed files with 400 additions and 193 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
use crate::messages::layout::utility_types::widgets::input_widgets::{SpectrumInputUpdate, VisualColorPickersInputUpdate};
use crate::messages::prelude::*;
use graphene_std::vector::style::FillChoice;
use graphene_std::vector::style::{FillChoice, GradientSpreadMethod};
/// Identifies which RGB channel a numeric input change targets.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
@@ -47,6 +47,8 @@ pub enum ColorPickerMessage {
/// `SpectrumInput` change: marker move/insert/delete, midpoint move/reset, or active marker selection changed.
GradientUpdate { update: SpectrumInputUpdate },
/// Spread method choice from the gradient "Ends" selection.
SetSpreadMethod { spread_method: GradientSpreadMethod },
/// Tell the frontend to start an undo transaction (forwarded as a `FrontendMessage` it bridges out to the picker's parent).
StartTransaction,

View File

@@ -5,7 +5,7 @@ use crate::messages::prelude::*;
use graphene_std::Color;
use graphene_std::color::SRGBA8;
use graphene_std::core_types::misc::parse_css_color;
use graphene_std::vector::style::{FillChoice, Gradient, GradientRamp, GradientStops};
use graphene_std::vector::style::{FillChoice, Gradient, GradientRamp, GradientSpreadMethod, GradientStops};
/// Bounds for a midpoint position (relative to the interval between two adjacent gradient stops).
const MIN_MIDPOINT: f64 = 0.01;
@@ -29,6 +29,7 @@ pub struct ColorPickerMessageHandler {
// When set, the picker is editing a gradient: the visual pickers and inputs target the active stop's color.
gradient: Option<Gradient>,
spread_method: GradientSpreadMethod,
active_marker_index: Option<u32>,
active_marker_is_midpoint: bool,
@@ -50,6 +51,7 @@ impl Default for ColorPickerMessageHandler {
old_alpha: 1.,
old_is_none: true,
gradient: None,
spread_method: GradientSpreadMethod::default(),
active_marker_index: None,
active_marker_is_midpoint: false,
allow_none: true,
@@ -70,11 +72,13 @@ impl MessageHandler<ColorPickerMessage, ()> for ColorPickerMessageHandler {
FillChoice::None => {
self.set_new_hsva(0., 0., 0., 1., true);
self.gradient = None;
self.spread_method = GradientSpreadMethod::default();
self.active_marker_index = None;
self.active_marker_is_midpoint = false;
}
FillChoice::Solid(color) => {
self.gradient = None;
self.spread_method = GradientSpreadMethod::default();
self.active_marker_index = None;
self.active_marker_is_midpoint = false;
self.adopt_color(color);
@@ -82,6 +86,7 @@ impl MessageHandler<ColorPickerMessage, ()> for ColorPickerMessageHandler {
FillChoice::Gradient(ramp) => {
self.active_marker_index = Some(0);
self.active_marker_is_midpoint = false;
self.spread_method = ramp.spread_method;
let gradient = Gradient::from(ramp);
let first_color = gradient.color(0).unwrap_or(Color::BLACK);
self.gradient = Some(gradient);
@@ -187,6 +192,18 @@ impl MessageHandler<ColorPickerMessage, ()> for ColorPickerMessageHandler {
self.send_layouts(responses);
}
ColorPickerMessage::GradientUpdate { update } => self.apply_gradient_update(update, responses),
ColorPickerMessage::SetSpreadMethod { spread_method } => {
let Some(gradient) = &self.gradient else { return };
responses.add(FrontendMessage::ColorPickerStartHistoryTransaction);
self.spread_method = spread_method;
responses.add(FrontendMessage::ColorPickerColorChanged {
value: FillChoice::Gradient(GradientRamp {
spread_method,
..GradientRamp::from(gradient)
}),
});
self.send_layouts(responses);
}
ColorPickerMessage::StartTransaction => {
responses.add(FrontendMessage::ColorPickerStartHistoryTransaction);
}
@@ -271,7 +288,10 @@ impl ColorPickerMessageHandler {
{
gradient.set_color(active_index as usize, color);
responses.add(FrontendMessage::ColorPickerColorChanged {
value: FillChoice::Gradient(GradientRamp::from(&*gradient)),
value: FillChoice::Gradient(GradientRamp {
spread_method: self.spread_method,
..GradientRamp::from(&*gradient)
}),
});
} else {
responses.add(FrontendMessage::ColorPickerColorChanged {
@@ -398,7 +418,10 @@ impl ColorPickerMessageHandler {
}
responses.add(FrontendMessage::ColorPickerColorChanged {
value: FillChoice::Gradient(GradientRamp::from(&gradient)),
value: FillChoice::Gradient(GradientRamp {
spread_method: self.spread_method,
..GradientRamp::from(&gradient)
}),
});
self.gradient = Some(gradient);
self.send_layouts(responses);
@@ -598,6 +621,24 @@ impl ColorPickerMessageHandler {
.widget_instance(),
]));
// Gradient ends spread method (only present when the picker is in gradient mode)
if self.gradient.is_some() {
let entries = [GradientSpreadMethod::Pad, GradientSpreadMethod::Reflect, GradientSpreadMethod::Repeat]
.into_iter()
.map(|spread_method| {
RadioEntryData::new(format!("{spread_method:?}"))
.label(spread_method.to_string())
.on_update(move |_| ColorPickerMessage::SetSpreadMethod { spread_method }.into())
})
.collect();
groups.push(LayoutGroup::row(vec![
TextLabel::new("Ends").tooltip_label("Spread Method").tooltip_description(ENDS_DESCRIPTION).widget_instance(),
Separator::new(SeparatorStyle::Related).widget_instance(),
RadioInput::new(entries).selected_index(Some(self.spread_method as u32)).disabled(self.disabled).widget_instance(),
]));
}
// Color presets (None / Black / White / pure colors / eyedropper)
groups.push(LayoutGroup::row(vec![
ColorPresetsInput::default()
@@ -651,6 +692,12 @@ const HUE_DESCRIPTION: &str = "The shade along the spectrum of the rainbow.";
const SATURATION_DESCRIPTION: &str = "The vividness from grayscale to full color.";
const VALUE_DESCRIPTION: &str = "The brightness from black to full color.";
const ALPHA_DESCRIPTION: &str = "The level of translucency, from transparent (0%) to opaque (100%).";
const ENDS_DESCRIPTION: &str = "\
How the gradient continues beyond its ends:\n\
**Pad** extends the end colors outward.\n\
**Reflect** loops the gradient by mirroring back-and-forth.\n\
**Repeat** loops the gradient as copies of itself.\
";
/// The popover's background color as sRGB gamma-encoded channels (the `--color-2-mildblack` design token, `#222`).
/// Used by the comparison swatch's outline computation to brighten the inset border for colors close to this background.

View File

@@ -411,6 +411,7 @@ impl<'a> ModifyInputsContext<'a> {
let backup_input_connector = InputConnector::node(fill_node_id, graphene_std::vector::fill::BackupGradientInput);
let ramp = GradientRamp::from(gradient);
let ramp = GradientRamp { spread_method, ..ramp };
self.set_input_with_refresh(backup_input_connector, NodeInput::value(TaggedValue::GradientRamp(ramp.clone()), false), true);
// Skip the rerender on all but the last input so the whole update triggers a single graph run
@@ -444,12 +445,6 @@ impl<'a> ModifyInputsContext<'a> {
self.set_input_with_refresh(
InputConnector::node(fill_node_id, graphene_std::vector::fill::GradientTypeInput),
NodeInput::value(TaggedValue::GradientType(gradient_type), false),
true,
);
self.set_input_with_refresh(
InputConnector::node(fill_node_id, graphene_std::vector::fill::SpreadMethodInput),
NodeInput::value(TaggedValue::GradientSpreadMethod(spread_method), false),
false,
);
}

View File

@@ -2461,13 +2461,23 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte
};
match &fill {
ResolvedFill::Gradient { gradient: stops, .. } => {
ResolvedFill::Gradient { gradient: stops, spread_method, .. } => {
let stops = stops.clone();
let spread_method = *spread_method;
let reverse_button = IconButton::new("Reverse", 24)
.tooltip_label("Reverse Stops")
.tooltip_description("Reverse the gradient color stops.")
.on_update(update_value(move |_| TaggedValue::GradientRamp(GradientRamp::from(stops.reversed())), node_id, FillInput))
.on_update(update_value(
move |_| {
TaggedValue::GradientRamp(GradientRamp {
spread_method,
..GradientRamp::from(stops.reversed())
})
},
node_id,
FillInput,
))
.widget_instance();
widgets_first_row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance());
widgets_first_row.push(reverse_button);
@@ -2483,7 +2493,10 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte
FillChoice::<SRGBA8>::None
}
}
ResolvedFill::Gradient { gradient: stops, .. } => FillChoice::<SRGBA8>::Gradient(GradientRamp::from(stops)),
ResolvedFill::Gradient { gradient: stops, spread_method, .. } => FillChoice::<SRGBA8>::Gradient(GradientRamp {
spread_method: *spread_method,
..GradientRamp::from(stops)
}),
ResolvedFill::Other => FillChoice::<SRGBA8>::None,
};
@@ -2572,36 +2585,14 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte
if let ResolvedFill::Gradient {
gradient_type,
spread_method,
transform,
transform_is_value,
..
} = fill.clone()
{
// Linear/Radial radio: blank assist (the "Reverse Direction" button has been moved down to the spread method row)
let mut row = vec![TextLabel::new("").widget_instance()];
add_blank_assist(&mut row);
let entries = [GradientType::Linear, GradientType::Radial]
.iter()
.map(|&grad_type| {
RadioEntryData::new(format!("{:?}", grad_type))
.label(format!("{:?}", grad_type))
.on_update(update_value(move |_| TaggedValue::GradientType(grad_type), node_id, GradientTypeInput))
.on_commit(commit_value)
})
.collect();
row.extend_from_slice(&[
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
RadioInput::new(entries).selected_index(Some(gradient_type as u32)).widget_instance(),
]);
widgets.push(LayoutGroup::row(row));
// "Reverse Direction" button (assist) plus the Pad/Reflect/Repeat radio. Icon orientation is resolved in viewport
// "Reverse Direction" button (assist) beside the Linear/Radial radio. Icon orientation is resolved in viewport
// space so canvas tilt and layer transforms behave the same as in the Gradient tool's control bar.
let mut spread_methods_row = vec![TextLabel::new("").widget_instance()];
let mut row = vec![TextLabel::new("").widget_instance()];
// The button writes a value into the transform input, so only offer it when the input isn't wired
if transform_is_value {
@@ -2612,11 +2603,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte
let reverse_direction_button = IconButton::new(if orientation_rightward { "ReverseRadialGradientToRight" } else { "ReverseRadialGradientToLeft" }, 24)
.tooltip_label("Reverse Direction")
.tooltip_description(if gradient_type == GradientType::Radial {
"Reverse which end the gradient radiates from."
} else {
"Swap the start and end points of the gradient line."
})
.tooltip_description(graph_modification_utils::reverse_direction_tooltip_description(gradient_type))
.on_update(move |_| Message::Batched {
messages: Box::new([
NodeGraphMessage::SetInputValue {
@@ -2634,28 +2621,28 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte
]),
})
.widget_instance();
spread_methods_row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance());
spread_methods_row.push(reverse_direction_button);
row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance());
row.push(reverse_direction_button);
} else {
add_blank_assist(&mut spread_methods_row);
add_blank_assist(&mut row);
}
let spread_method_entries = [GradientSpreadMethod::Pad, GradientSpreadMethod::Reflect, GradientSpreadMethod::Repeat]
let entries = [GradientType::Linear, GradientType::Radial]
.iter()
.map(|&spread_method| {
RadioEntryData::new(format!("{:?}", spread_method))
.label(format!("{:?}", spread_method))
.on_update(update_value(move |_| TaggedValue::GradientSpreadMethod(spread_method), node_id, SpreadMethodInput))
.map(|&gradient_type| {
RadioEntryData::new(format!("{:?}", gradient_type))
.label(format!("{:?}", gradient_type))
.on_update(update_value(move |_| TaggedValue::GradientType(gradient_type), node_id, GradientTypeInput))
.on_commit(commit_value)
})
.collect();
spread_methods_row.extend_from_slice(&[
row.extend_from_slice(&[
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
RadioInput::new(spread_method_entries).selected_index(Some(spread_method as u32)).widget_instance(),
RadioInput::new(entries).selected_index(Some(gradient_type as u32)).widget_instance(),
]);
widgets.push(LayoutGroup::row(spread_methods_row));
widgets.push(LayoutGroup::row(row));
}
widgets

View File

@@ -784,7 +784,7 @@ async fn legacy_four_input_fill_migrates_to_the_split_transform_shape() {
let network = document.network_interface.nested_network(&network_path).expect("the found network path should resolve");
let fill_node = &network.nodes[&node_id];
assert_eq!(fill_node.inputs.len(), 8, "the legacy Fill should upgrade to the 8-input shape");
assert_eq!(fill_node.inputs.len(), 7, "the legacy Fill should upgrade to the 7-input shape");
let paint = fill_node.input(graphene_std::vector::fill::FillInput);
assert!(
matches!(paint, Some(graph_craft::document::NodeInput::Node { .. })),
@@ -811,3 +811,58 @@ async fn legacy_four_input_fill_migrates_to_the_split_transform_shape() {
assert_eq!(stops.len(), 2);
assert!(!stops.has_position_attribute(), "even legacy tuple positions should elide rather than materialize");
}
#[tokio::test]
async fn eight_input_fill_migrates_spread_method_into_the_ramp() {
use graph_craft::document::value::TaggedValue;
use graphene_std::vector::style::GradientSpreadMethod;
// A minimal document from the era when spread method was the Fill node's own sixth input, here set to Repeat
const EIGHT_INPUT_DOCUMENT: &str = r#"{"network_interface":{"network":{"exports":[{"Node":{"node_id":1,"output_index":0,"lambda":false}}],"nodes":[[1,{"inputs":[{"Value":{"tagged_value":{"GraphicGroup":{"instance":[],"transform":[],"alpha_blending":[],"source_node_id":[]}},"exposed":true}},{"Value":{"tagged_value":{"GradientRamp":{"stops":{"color":[{"red":0.0,"green":0.0,"blue":0.0,"alpha":1.0},{"red":1.0,"green":1.0,"blue":1.0,"alpha":1.0}]}}},"exposed":false}},{"Value":{"tagged_value":{"Color":{"red":0.0,"green":0.0,"blue":0.0,"alpha":1.0}},"exposed":false}},{"Value":{"tagged_value":{"GradientRamp":{"stops":{"color":[{"red":0.0,"green":0.0,"blue":0.0,"alpha":1.0},{"red":1.0,"green":1.0,"blue":1.0,"alpha":1.0}]}}},"exposed":false}},{"Value":{"tagged_value":{"GradientType":"Linear"},"exposed":false}},{"Value":{"tagged_value":{"GradientSpreadMethod":"Repeat"},"exposed":false}},{"Value":{"tagged_value":{"Bool":false},"exposed":false}},{"Value":{"tagged_value":{"DAffine2":[1.0,0.0,0.0,1.0,0.0,0.0]},"exposed":false}}],"manual_composition":{"Concrete":{"name":"core::option::Option<alloc::sync::Arc<graphene_core::context::OwnedContextImpl>>","alias":null}},"implementation":{"ProtoNode":{"name":"graphene_core::vector::FillNode"}},"visible":true,"skip_deduplication":false}]],"scope_injections":[]},"network_metadata":{"persistent_metadata":{"node_metadata":[[1,{"persistent_metadata":{"reference":"Fill","display_name":"","input_properties":[{"input_data":{"input_name":"Content"},"widget_override":null},{"input_data":{"input_name":"Fill"},"widget_override":null},{"input_data":{"input_name":"Backup Color"},"widget_override":null},{"input_data":{"input_name":"Backup Gradient"},"widget_override":null},{"input_data":{"input_name":"Gradient Type"},"widget_override":null},{"input_data":{"input_name":"Spread Method"},"widget_override":null},{"input_data":{"input_name":"Has Transform"},"widget_override":null},{"input_data":{"input_name":"Transform"},"widget_override":null}],"output_names":[""],"has_primary_output":true,"locked":false,"pinned":false,"node_type_metadata":{"Node":{"position":{"Absolute":[0,0]}}},"network_metadata":null}}]],"previewing":"No","navigation_metadata":{"node_graph_ptz":{"pan":[0.0,0.0],"tilt":0.0,"zoom":1.0,"flip":false},"node_graph_to_viewport":[1.0,0.0,0.0,1.0,0.0,0.0],"node_graph_top_right":[0.0,0.0]},"selection_undo_history":[],"selection_redo_history":[]}}},"collapsed":[],"name":"eight_input_fill.graphite","commit_hash":"0000000000000000000000000000000000000000","document_ptz":{"pan":[0.0,0.0],"tilt":0.0,"zoom":1.0,"flip":false},"document_mode":"DesignMode","view_mode":"Normal","overlays_visibility_settings":{"all":true,"artboard_name":true,"compass_rose":true,"quick_measurement":true,"transform_measurement":true,"transform_cage":true,"hover_outline":true,"selection_outline":true,"pivot":true,"path":true,"anchors":true,"handles":true},"rulers_visible":true,"snapping_state":{"snapping_enabled":true,"grid_snapping":false,"artboards":true,"tolerance":8.0,"bounding_box":{"center_point":true,"corner_point":true,"edge_midpoint":true,"align_with_edges":true,"distribute_evenly":true},"path":{"anchor_point":true,"line_midpoint":true,"along_path":true,"normal_to_path":true,"tangent_to_path":true,"path_intersection_point":true,"align_with_anchor_point":true,"perpendicular_from_endpoint":true},"grid":{"origin":[0.0,0.0],"grid_type":{"Rectangular":{"spacing":[1.0,1.0]}},"grid_color":{"red":0.6,"green":0.6,"blue":0.6,"alpha":1.0},"dot_display":false}},"graph_view_overlay_open":false,"graph_fade_artwork_percentage":80.0}"#;
// Deserializing alone must succeed, so a failure below is attributable to the migrations
DocumentMessageHandler::deserialize_document(EIGHT_INPUT_DOCUMENT).expect("the eight-input document should deserialize");
let mut editor = EditorTestUtils::create();
editor
.handle_message(PortfolioMessage::OpenDocumentFile {
document_name: None,
document_path: None,
document_serialized_content: EIGHT_INPUT_DOCUMENT.to_string(),
})
.await;
let document = editor.active_document();
let (network_path, node_id) = find_fill_node(document);
let network = document.network_interface.nested_network(&network_path).expect("the found network path should resolve");
let fill_node = &network.nodes[&node_id];
assert_eq!(fill_node.inputs.len(), 7, "the eight-input Fill should fold down to the 7-input shape");
let paint = fill_node.input_value(graphene_std::vector::fill::FillInput);
let Some(TaggedValue::GradientRamp(ramp)) = paint else {
panic!("the fill input should keep its gradient ramp value, but became {paint:?}");
};
assert_eq!(ramp.spread_method, GradientSpreadMethod::Repeat, "the spread method input's value should fold into the fill ramp");
let backup = fill_node.input_value(graphene_std::vector::fill::BackupGradientInput);
let Some(TaggedValue::GradientRamp(backup_ramp)) = backup else {
panic!("the backup gradient input should keep its gradient ramp value, but became {backup:?}");
};
assert_eq!(
backup_ramp.spread_method,
GradientSpreadMethod::Repeat,
"the spread method input's value should fold into the backup ramp"
);
let has_transform = fill_node.input_value(graphene_std::vector::fill::HasTransformInput);
assert!(
matches!(has_transform, Some(TaggedValue::Bool(false))),
"the has-transform input should shift down intact, but became {has_transform:?}"
);
let transform = fill_node.input_value(graphene_std::vector::fill::TransformInput);
assert!(
matches!(transform, Some(TaggedValue::DAffine2(_))),
"the transform input should shift down intact, but became {transform:?}"
);
}

View File

@@ -17,7 +17,7 @@ use graphene_std::text::{TextAlign, TypesettingConfig};
use graphene_std::transform::ScaleType;
use graphene_std::uuid::NodeId;
use graphene_std::vector::graphic_types;
use graphene_std::vector::style::{PaintOrder, StrokeAlign};
use graphene_std::vector::style::{GradientRamp, GradientSpreadMethod, PaintOrder, StrokeAlign};
use std::collections::HashMap;
use std::f64::consts::PI;
use std::ops::Range;
@@ -1413,6 +1413,14 @@ fn migrate_corner_radius_input(input: &NodeInput) -> Option<NodeInput> {
Some(NodeInput::value(TaggedValue::BoxCorners(values), *exposed))
}
/// Rewrites a gradient ramp value input to carry the given spread method, which used to live in the Fill node's retired `_spread_method` input.
fn fold_spread_method_into_ramp_input(input: &NodeInput, spread_method: GradientSpreadMethod) -> NodeInput {
match input.as_value() {
Some(TaggedValue::GradientRamp(ramp)) => NodeInput::value(TaggedValue::GradientRamp(GradientRamp { spread_method, ..ramp.clone() }), input.is_exposed()),
_ => input.clone(),
}
}
fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], document: &mut DocumentMessageHandler, reset_node_definitions_on_open: bool) -> Option<()> {
// Must run before the reset block below: a node referencing a removed catalog entry would otherwise abort
// `migrate_node` via the `?` on `resolve_document_node_type`, preventing subsequent migration blocks from running.
@@ -1657,7 +1665,7 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
}
// Upgrade the legacy 4-input Fill node (content, fill: Fill, _backup_color, _backup_gradient: Gradient) to the value-model
// 8-input shape (content, fill: generic paint list, _backup_color, _backup_gradient, _gradient_type, _spread_method, _has_transform, _transform).
// 7-input shape (content, fill: generic paint list, _backup_color, _backup_gradient, _gradient_type, _has_transform, _transform).
if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector_nodes::fill::IDENTIFIER) && inputs_count == 4 {
let mut node_template = resolve_document_node_type(&reference)?.default_node_template();
document.network_interface.replace_implementation(node_id, network_path, &mut node_template);
@@ -1673,33 +1681,31 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
let fill_value = match old_fill {
graphic_types::migrations::legacy::LegacyFill::None => TaggedValue::no_paint(),
graphic_types::migrations::legacy::LegacyFill::Solid(color) => TaggedValue::Color(*color),
graphic_types::migrations::legacy::LegacyFill::Gradient(gradient) => TaggedValue::GradientRamp(gradient.stops.clone()),
graphic_types::migrations::legacy::LegacyFill::Gradient(gradient) => TaggedValue::GradientRamp(GradientRamp {
spread_method: gradient.spread_method,
..gradient.stops.clone()
}),
};
document
.network_interface
.set_input(&InputConnector::node_at_index(*node_id, 1), NodeInput::value(fill_value, exposed), network_path);
// Gradient metadata (4, 5, 6, 7): applies only to a literal gradient, solids/none keep the template defaults
// Gradient metadata (4, 5, 6): applies only to a literal gradient, solids/none keep the template defaults
if let graphic_types::migrations::legacy::LegacyFill::Gradient(gradient) = old_fill {
document.network_interface.set_input(
&InputConnector::node_at_index(*node_id, 4),
NodeInput::value(TaggedValue::GradientType(gradient.gradient_type), false),
network_path,
);
document.network_interface.set_input(
&InputConnector::node_at_index(*node_id, 5),
NodeInput::value(TaggedValue::GradientSpreadMethod(gradient.spread_method), false),
network_path,
);
if gradient.absolute {
let transform = gradient.transform * gradient.to_transform();
document
.network_interface
.set_input(&InputConnector::node_at_index(*node_id, 6), NodeInput::value(TaggedValue::Bool(true), false), network_path);
.set_input(&InputConnector::node_at_index(*node_id, 5), NodeInput::value(TaggedValue::Bool(true), false), network_path);
document
.network_interface
.set_input(&InputConnector::node_at_index(*node_id, 7), NodeInput::value(TaggedValue::DAffine2(transform), false), network_path);
.set_input(&InputConnector::node_at_index(*node_id, 6), NodeInput::value(TaggedValue::DAffine2(transform), false), network_path);
} else {
// Baking a legacy bounding-box-relative gradient is deferred until the measurement pre-pass can supply the paint
// target's bounds, so the template's unbaked `_has_transform = false` stands until the bake lands
@@ -1721,7 +1727,13 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
if let Some(TaggedValue::LegacyGradient(g)) = old_inputs[3].as_value() {
document.network_interface.set_input(
&InputConnector::node_at_index(*node_id, 3),
NodeInput::value(TaggedValue::GradientRamp(g.stops.clone()), false),
NodeInput::value(
TaggedValue::GradientRamp(GradientRamp {
spread_method: g.spread_method,
..g.stops.clone()
}),
false,
),
network_path,
);
@@ -1737,36 +1749,45 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
NodeInput::value(TaggedValue::GradientType(g.gradient_type), false),
network_path,
);
document.network_interface.set_input(
&InputConnector::node_at_index(*node_id, 5),
NodeInput::value(TaggedValue::GradientSpreadMethod(g.spread_method), false),
network_path,
);
if g.absolute {
let transform = g.transform * g.to_transform();
document
.network_interface
.set_input(&InputConnector::node_at_index(*node_id, 6), NodeInput::value(TaggedValue::Bool(true), false), network_path);
.set_input(&InputConnector::node_at_index(*node_id, 5), NodeInput::value(TaggedValue::Bool(true), false), network_path);
document
.network_interface
.set_input(&InputConnector::node_at_index(*node_id, 7), NodeInput::value(TaggedValue::DAffine2(transform), false), network_path);
.set_input(&InputConnector::node_at_index(*node_id, 6), NodeInput::value(TaggedValue::DAffine2(transform), false), network_path);
} else {
document.pending_gradient_bbox_bake.push((network_path.to_vec(), *node_id, g.clone()));
}
}
}
inputs_count = 8;
inputs_count = 7;
}
// Fill split its `Option<DAffine2>` placement into a `_has_transform` bool immediately before the `_transform` matrix
if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::fill::IDENTIFIER) && inputs_count == 7 {
// Fill split its `Option<DAffine2>` placement into a `_has_transform` bool immediately before the `_transform` matrix. The modern
// shape is also 7 inputs, so this era is identified by its `_spread_method` input at 5 or its optional transform at 6.
let is_pre_transform_split_fill = inputs_count == 7
&& (matches!(node.inputs.get(5).and_then(|input| input.as_value()), Some(TaggedValue::GradientSpreadMethod(_)))
|| matches!(node.inputs.get(6).and_then(|input| input.as_value()), Some(TaggedValue::LegacyOptionalDAffine2(_))));
if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::fill::IDENTIFIER) && is_pre_transform_split_fill {
let mut node_template = resolve_document_node_type(&reference)?.default_node_template();
let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?;
for (index, input) in old_inputs.iter().enumerate().take(6) {
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, index), input.clone(), network_path);
let spread_method = match old_inputs.get(5).and_then(|input| input.as_value()) {
Some(&TaggedValue::GradientSpreadMethod(value)) => value,
_ => GradientSpreadMethod::default(),
};
for (index, input) in old_inputs.iter().enumerate().take(5) {
let input = if index == 1 || index == 3 {
fold_spread_method_into_ramp_input(input, spread_method)
} else {
input.clone()
};
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, index), input, network_path);
}
match old_inputs.get(6).and_then(|input| input.as_value()) {
@@ -1775,22 +1796,46 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
let transform = value.unwrap_or(glam::DAffine2::IDENTITY);
document
.network_interface
.set_input(&InputConnector::node_at_index(*node_id, 6), NodeInput::value(TaggedValue::Bool(has_transform), false), network_path);
.set_input(&InputConnector::node_at_index(*node_id, 5), NodeInput::value(TaggedValue::Bool(has_transform), false), network_path);
document
.network_interface
.set_input(&InputConnector::node_at_index(*node_id, 7), NodeInput::value(TaggedValue::DAffine2(transform), false), network_path);
.set_input(&InputConnector::node_at_index(*node_id, 6), NodeInput::value(TaggedValue::DAffine2(transform), false), network_path);
}
// A wired (or otherwise non-value) transform keeps its connection and is treated as present
_ => {
document
.network_interface
.set_input(&InputConnector::node_at_index(*node_id, 6), NodeInput::value(TaggedValue::Bool(true), false), network_path);
.set_input(&InputConnector::node_at_index(*node_id, 5), NodeInput::value(TaggedValue::Bool(true), false), network_path);
let transform_input = old_inputs.get(6).cloned().unwrap_or_else(|| NodeInput::value(TaggedValue::DAffine2(glam::DAffine2::IDENTITY), false));
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 7), transform_input, network_path);
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 6), transform_input, network_path);
}
}
inputs_count = 8;
inputs_count = 7;
}
// The Fill node's `_spread_method` input moved into the `GradientRamp` value's own `spread_method` field
if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::fill::IDENTIFIER) && inputs_count == 8 {
let mut node_template = resolve_document_node_type(&reference)?.default_node_template();
let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?;
let spread_method = match old_inputs.get(5).and_then(|input| input.as_value()) {
Some(&TaggedValue::GradientSpreadMethod(value)) => value,
_ => GradientSpreadMethod::default(),
};
for (index, input) in old_inputs.iter().enumerate().take(5) {
let input = if index == 1 || index == 3 {
fold_spread_method_into_ramp_input(input, spread_method)
} else {
input.clone()
};
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, index), input, network_path);
}
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 5), old_inputs[6].clone(), network_path);
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 6), old_inputs[7].clone(), network_path);
inputs_count = 7;
}
// Upgrade Stroke node to reorder parameters and add "Align" and "Paint Order" (#2644)

View File

@@ -381,6 +381,14 @@ pub fn gradient_space_transform(layer: LayerNodeIdentifier, network_interface: &
metadata.transform_to_viewport(layer)
}
/// Tooltip description for a "Reverse Direction" gradient button, phrased for the given gradient type.
pub fn reverse_direction_tooltip_description(gradient_type: GradientType) -> &'static str {
match gradient_type {
GradientType::Radial => "Reverse which end the gradient radiates from.",
GradientType::Linear => "Swap the start and end points of the gradient line.",
}
}
/// True when start→end (mapped through `transform` into viewport space) points predominantly rightward. For purely
/// vertical lines we fall back to a stable tiebreaker on (x + y) so the choice doesn't flicker between equal alternatives.
pub fn gradient_orientation_rightward(transform: glam::DAffine2) -> bool {
@@ -665,15 +673,12 @@ pub fn read_fill_node_gradient(fill_node: &DocumentNode, bounding_box: impl FnOn
let TaggedValue::GradientRamp(ramp) = fill_node.input(fill::FillInput)?.as_value()? else {
return None;
};
let spread_method = ramp.spread_method;
let stops = Gradient::from(ramp);
let gradient_type = match fill_node.input(fill::GradientTypeInput).and_then(|input| input.as_value()) {
Some(&TaggedValue::GradientType(value)) => value,
_ => GradientType::default(),
};
let spread_method = match fill_node.input(fill::SpreadMethodInput).and_then(|input| input.as_value()) {
Some(&TaggedValue::GradientSpreadMethod(value)) => value,
_ => GradientSpreadMethod::default(),
};
let has_transform = matches!(fill_node.input(fill::HasTransformInput).and_then(|input| input.as_value()), Some(&TaggedValue::Bool(true)));
let transform_input = fill_node.input(fill::TransformInput).and_then(|input| input.as_value());
let transform = match (has_transform, transform_input) {
@@ -823,10 +828,6 @@ pub fn set_fill_for_selected_layers(fill_choice: FillChoice, document: &Document
Some(TaggedValue::GradientType(value)) => *value,
_ => GradientType::default(),
};
let spread_method = match fill_parameters.as_ref().and_then(|parameters| parameters.value(fill::SpreadMethodInput)) {
Some(TaggedValue::GradientSpreadMethod(value)) => *value,
_ => GradientSpreadMethod::default(),
};
let has_transform = matches!(fill_parameters.as_ref().and_then(|parameters| parameters.value(fill::HasTransformInput)), Some(TaggedValue::Bool(true)));
let transform = match (has_transform, fill_parameters.as_ref().and_then(|parameters| parameters.value(fill::TransformInput))) {
(true, Some(TaggedValue::DAffine2(value))) => *value,
@@ -838,7 +839,7 @@ pub fn set_fill_for_selected_layers(fill_choice: FillChoice, document: &Document
layer,
gradient: Gradient::from(ramp),
gradient_type,
spread_method,
spread_method: ramp.spread_method,
transform,
});
}

View File

@@ -9,14 +9,14 @@ use crate::messages::portfolio::document::utility_types::document_metadata::Laye
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_fill_node_id_with_direct_fill_input, get_gradient_stops, get_upstream_gradient_value_node_id, gradient_chain_target_input,
self, NodeGraphLayer, get_fill_node_id_with_direct_fill_input, get_gradient_stops, get_upstream_gradient_value_node_id, gradient_chain_target_input, reverse_direction_tooltip_description,
};
use crate::messages::tool::common_functionality::snapping::{SnapCandidatePoint, SnapConstraint, SnapData, SnapManager, SnapTypeConfiguration};
use glam::DMat2;
use graph_craft::document::value::TaggedValue;
use graphene_std::color::SRGBA8;
use graphene_std::raster::color::Color;
use graphene_std::vector::style::{FillChoice, Gradient, GradientRamp, GradientSpreadMethod, GradientStop, GradientStops, GradientType, build_transform_with_y_preservation};
use graphene_std::vector::style::{FillChoice, Gradient, GradientRamp, GradientSpreadMethod, GradientStop, GradientType, build_transform_with_y_preservation};
#[derive(Default, ExtractField)]
pub struct GradientTool {
@@ -53,7 +53,7 @@ pub enum GradientToolMessage {
CommitTransactionForColorStop,
CloseStopColorPicker,
UpdateStopColor { color: Color },
UpdateStops { stops: GradientStops<SRGBA8> },
UpdateRamp { ramp: GradientRamp<SRGBA8> },
UpdateOptions { options: GradientOptionsUpdate },
}
@@ -63,7 +63,6 @@ pub enum GradientOptionsUpdate {
Type(GradientType),
ReverseStops,
ReverseDirection,
SetSpreadMethod(GradientSpreadMethod),
}
impl ToolMetadata for GradientTool {
@@ -111,16 +110,6 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for Grad
appearance.transform *= reverse;
},
),
GradientOptionsUpdate::SetSpreadMethod(spread_method) => {
self.options.spread_method = spread_method;
apply_gradient_update(
&mut self.data,
context,
responses,
|(_gradient, appearance)| appearance.spread_method != spread_method,
|(_gradient, appearance)| appearance.spread_method = spread_method,
);
}
},
ToolMessage::Gradient(GradientToolMessage::StartTransactionForColorStop) => {
if self.data.color_picker_transaction_open {
@@ -145,8 +134,10 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for Grad
responses.add(PropertiesPanelMessage::Refresh);
}
}
ToolMessage::Gradient(GradientToolMessage::UpdateStops { stops }) => {
apply_stops_update(&mut self.data, context, responses, Gradient::from(&stops));
ToolMessage::Gradient(GradientToolMessage::UpdateRamp { ramp }) => {
let ramp = GradientRamp::from(&ramp);
self.options.spread_method = ramp.spread_method;
apply_stops_update(&mut self.data, context, responses, Gradient::from(&ramp), ramp.spread_method);
}
ToolMessage::Gradient(GradientToolMessage::CloseStopColorPicker) => {
if self.data.color_picker_transaction_open {
@@ -271,17 +262,20 @@ impl LayoutHolder for GradientTool {
},
])
});
let stops_widget = ColorInput::new(FillChoice::Gradient(GradientRamp::from(&stops_value)))
.allow_none(false)
.narrow(true)
.tooltip_label("Gradient Stops")
.tooltip_description("Edit the gradient's color stops.")
.on_update(|input: &ColorInput| {
let stops = input.value.as_gradient().map(|ramp| ramp.stops.clone()).unwrap_or_default();
GradientToolMessage::UpdateStops { stops }.into()
})
.on_commit(|_| DocumentMessage::AddTransaction.into())
.widget_instance();
let stops_widget = ColorInput::new(FillChoice::Gradient(GradientRamp {
spread_method: self.options.spread_method,
..GradientRamp::from(&stops_value)
}))
.allow_none(false)
.narrow(true)
.tooltip_label("Gradient Stops")
.tooltip_description("Edit the gradient's color stops.")
.on_update(|input: &ColorInput| {
let ramp = input.value.as_gradient().cloned().unwrap_or_default();
GradientToolMessage::UpdateRamp { ramp }.into()
})
.on_commit(|_| DocumentMessage::AddTransaction.into())
.widget_instance();
let reverse_stops = IconButton::new("Reverse", 24)
.tooltip_label("Reverse Stops")
@@ -295,29 +289,6 @@ impl LayoutHolder for GradientTool {
})
.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 {
@@ -325,7 +296,7 @@ impl LayoutHolder for GradientTool {
};
let reverse_direction = IconButton::new(reverse_direction_icon, 24)
.tooltip_label("Reverse Direction")
.tooltip_description("Reverse which end the gradient radiates from.")
.tooltip_description(reverse_direction_tooltip_description(self.options.gradient_type))
.disabled(!self.data.has_selected_gradient)
.on_update(|_| {
GradientToolMessage::UpdateOptions {
@@ -341,8 +312,6 @@ impl LayoutHolder for GradientTool {
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,
]);
@@ -1891,7 +1860,7 @@ fn apply_gradient_update(
/// 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<Message>, new_gradient: Gradient) {
fn apply_stops_update(data: &mut GradientToolData, context: &mut ToolActionMessageContext, responses: &mut VecDeque<Message>, new_gradient: Gradient, spread_method: GradientSpreadMethod) {
let selected_layers: Vec<_> = context
.document
.network_interface
@@ -1907,13 +1876,14 @@ fn apply_stops_update(data: &mut GradientToolData, context: &mut ToolActionMessa
if get_upstream_gradient_value_node_id(layer, &context.document.network_interface).is_some() {
responses.add(GraphOperationMessage::GradientStopsSet { layer, stops: new_gradient.clone() });
responses.add(GraphOperationMessage::GradientSpreadMethodSet { layer, spread_method });
updated_any_layer = true;
} else if let Some((_gradient, appearance, _source)) = resolve_gradient(layer, &context.document.network_interface) {
responses.add(GraphOperationMessage::FillGradientSet {
layer,
gradient: new_gradient.clone(),
gradient_type: appearance.gradient_type,
spread_method: appearance.spread_method,
spread_method,
transform: appearance.transform,
});
updated_any_layer = true;
@@ -1922,6 +1892,7 @@ fn apply_stops_update(data: &mut GradientToolData, context: &mut ToolActionMessa
if let Some(selected_gradient) = &mut data.selected_gradient {
selected_gradient.gradient = new_gradient.clone();
selected_gradient.appearance.spread_method = spread_method;
}
// When no selected layer had a gradient to update, the user is editing the tool's default gradient instead.
@@ -2011,6 +1982,7 @@ mod test_gradient {
use crate::messages::portfolio::document::utility_types::misc::GroupFolderType;
use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, OutputConnector};
use crate::messages::tool::common_functionality::graph_modification_utils::get_fill_node_id_with_direct_fill_input;
use crate::messages::tool::common_functionality::graph_modification_utils::get_gradient_stops;
use crate::messages::tool::common_functionality::graph_modification_utils::get_upstream_gradient_value_node_id;
pub use crate::test_utils::test_prelude::*;
use glam::DAffine2;
@@ -2060,16 +2032,11 @@ mod test_gradient {
let fill_node_id = get_fill_node_id_with_direct_fill_input(layer, &document.network_interface)?;
let fill_node = document.network_interface.document_network().nodes.get(&fill_node_id)?;
let stops = match fill_node.input(fill::FillInput)?.as_value()? {
TaggedValue::GradientRamp(ramp) => Gradient::from(ramp),
let (stops, spread_method) = match fill_node.input(fill::FillInput)?.as_value()? {
TaggedValue::GradientRamp(ramp) => (Gradient::from(ramp), ramp.spread_method),
_ => return None,
};
let spread_method = match fill_node.input(fill::SpreadMethodInput).and_then(|input| input.as_value()) {
Some(&TaggedValue::GradientSpreadMethod(value)) => value,
_ => GradientSpreadMethod::default(),
};
let has_transform = matches!(fill_node.input(fill::HasTransformInput).and_then(|input| input.as_value()), Some(&TaggedValue::Bool(true)));
let local_transform = match fill_node.input(fill::TransformInput).and_then(|input| input.as_value()) {
Some(&TaggedValue::DAffine2(value)) if has_transform => value,
@@ -2606,6 +2573,14 @@ mod test_gradient {
assert_eq!(editor.active_document().metadata().all_layers().count(), 0, "Expected the layer to be deleted after drawing a gradient");
}
/// Build the JS-boundary ramp the stops swatch's picker would send when choosing a new spread method.
fn ramp_with_spread(stops: &Gradient, spread_method: GradientSpreadMethod) -> GradientRamp<SRGBA8> {
GradientRamp::<SRGBA8>::from(&GradientRamp {
spread_method,
..GradientRamp::from(stops)
})
}
#[tokio::test]
async fn change_spread_method() {
let mut editor = EditorTestUtils::create();
@@ -2619,8 +2594,8 @@ mod test_gradient {
// Update spread method to Repeat
editor
.handle_message(GradientToolMessage::UpdateOptions {
options: GradientOptionsUpdate::SetSpreadMethod(GradientSpreadMethod::Repeat),
.handle_message(GradientToolMessage::UpdateRamp {
ramp: ramp_with_spread(&gradient.stops, GradientSpreadMethod::Repeat),
})
.await;
@@ -2629,8 +2604,8 @@ mod test_gradient {
// Update spread method to Reflect
editor
.handle_message(GradientToolMessage::UpdateOptions {
options: GradientOptionsUpdate::SetSpreadMethod(GradientSpreadMethod::Reflect),
.handle_message(GradientToolMessage::UpdateRamp {
ramp: ramp_with_spread(&gradient.stops, GradientSpreadMethod::Reflect),
})
.await;
@@ -2652,8 +2627,8 @@ mod test_gradient {
// Update spread method to Repeat
editor
.handle_message(GradientToolMessage::UpdateOptions {
options: GradientOptionsUpdate::SetSpreadMethod(GradientSpreadMethod::Repeat),
.handle_message(GradientToolMessage::UpdateRamp {
ramp: ramp_with_spread(&gradient.stops, GradientSpreadMethod::Repeat),
})
.await;
@@ -2662,8 +2637,8 @@ mod test_gradient {
// Update spread method to Reflect
editor
.handle_message(GradientToolMessage::UpdateOptions {
options: GradientOptionsUpdate::SetSpreadMethod(GradientSpreadMethod::Reflect),
.handle_message(GradientToolMessage::UpdateRamp {
ramp: ramp_with_spread(&gradient.stops, GradientSpreadMethod::Reflect),
})
.await;
@@ -2880,9 +2855,10 @@ mod test_gradient {
// Set the spread method through the tool, which splices a 'Spread Method' node onto the Fill's fill input wire.
editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }).await;
editor.select_tool(ToolType::Gradient).await;
let stops = get_gradient_stops(layer, &editor.active_document().network_interface).expect("the chain layer should resolve its gradient stops");
editor
.handle_message(GradientToolMessage::UpdateOptions {
options: GradientOptionsUpdate::SetSpreadMethod(GradientSpreadMethod::Reflect),
.handle_message(GradientToolMessage::UpdateRamp {
ramp: ramp_with_spread(&stops, GradientSpreadMethod::Reflect),
})
.await;

View File

@@ -167,7 +167,15 @@ macro_rules! tagged_value {
Self::DashPattern(lengths) => Box::new(DashPattern::from(lengths)),
Self::BoxCorners(values) => Box::new(BoxCorners::from(values)),
Self::Color(color) => Box::new(List::<Color>::new_from_element(color)),
Self::GradientRamp(ramp) => Box::new(List::<Gradient>::new_from_element(Gradient::from(ramp))),
Self::GradientRamp(ramp) => {
// The ramp's spread method rides the served list as its attribute, as `Item<Gradient>::from` does on master.
let spread_method = ramp.spread_method;
let mut list = List::<Gradient>::new_from_element(Gradient::from(ramp));
if !spread_method.is_default() {
list.set_attribute(graphic_types::vector_types::ATTR_SPREAD_METHOD, 0, spread_method);
}
Box::new(list)
}
Self::BrushStrokes(strokes) => {
let list: List<BrushStroke> = strokes.into_iter().map(core_types::list::Item::new_from_element).collect();
Box::new(list)
@@ -213,7 +221,15 @@ macro_rules! tagged_value {
Self::DashPattern(lengths) => Arc::new(DashPattern::from(lengths)),
Self::BoxCorners(values) => Arc::new(BoxCorners::from(values)),
Self::Color(color) => Arc::new(List::<Color>::new_from_element(color)),
Self::GradientRamp(ramp) => Arc::new(List::<Gradient>::new_from_element(Gradient::from(ramp))),
Self::GradientRamp(ramp) => {
// The ramp's spread method rides the served list as its attribute, as `Item<Gradient>::from` does on master.
let spread_method = ramp.spread_method;
let mut list = List::<Gradient>::new_from_element(Gradient::from(ramp));
if !spread_method.is_default() {
list.set_attribute(graphic_types::vector_types::ATTR_SPREAD_METHOD, 0, spread_method);
}
Arc::new(list)
}
Self::BrushStrokes(strokes) => {
let list: List<BrushStroke> = strokes.into_iter().map(core_types::list::Item::new_from_element).collect();
Arc::new(list)
@@ -1086,6 +1102,8 @@ mod paint_default_parsing {
#[cfg(test)]
mod gradient_shape_migration {
use graphic_types::vector_types::GradientSpreadMethod;
use super::*;
fn load(payload: serde_json::Value) -> TaggedValue {
@@ -1104,7 +1122,10 @@ mod gradient_shape_migration {
fn modern_ramp_payload_round_trips() {
let mut gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]);
gradient.set_positions(&[0.2, 0.9]);
let value = TaggedValue::GradientRamp(GradientRamp::from(gradient));
let value = TaggedValue::GradientRamp(GradientRamp {
spread_method: GradientSpreadMethod::Reflect,
..GradientRamp::from(gradient)
});
let json = serde_json::to_value(&value).unwrap();
assert!(json.get("GradientRamp").and_then(|payload| payload.get("stops")).is_some(), "the payload should nest its stops: {json}");

View File

@@ -1,3 +1,4 @@
use crate::ATTR_SPREAD_METHOD;
use core_types::Color;
use core_types::color::SRGBA8;
use core_types::list::{ATTR_MIDPOINT, ATTR_POSITION, Item, List};
@@ -96,13 +97,15 @@ impl GradientStops<SRGBA8> {
}
}
/// The serialized exchange form of a gradient: its stops, nested so that whole-ramp settings
/// like spread method can join as sibling fields opted in from their defaults.
/// The serialized exchange form of a gradient: its stops, with whole-ramp settings as sibling fields serialized only when non-default.
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Default, Debug, Clone, PartialEq, graphene_hash::CacheHash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct GradientRamp<C = Color> {
pub stops: GradientStops<C>,
#[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "GradientSpreadMethod::is_default"))]
#[cfg_attr(feature = "wasm", tsify(optional))]
pub spread_method: GradientSpreadMethod,
}
unsafe impl<C: dyn_any::StaticTypeSized> dyn_any::StaticType for GradientRamp<C> {
@@ -111,13 +114,19 @@ unsafe impl<C: dyn_any::StaticTypeSized> dyn_any::StaticType for GradientRamp<C>
impl<C> From<GradientStops<C>> for GradientRamp<C> {
fn from(stops: GradientStops<C>) -> Self {
Self { stops }
Self {
stops,
spread_method: Default::default(),
}
}
}
impl From<&Gradient> for GradientRamp {
fn from(gradient: &Gradient) -> Self {
Self { stops: gradient.into() }
Self {
stops: gradient.into(),
spread_method: Default::default(),
}
}
}
@@ -139,6 +148,27 @@ impl From<&GradientRamp> for Gradient {
}
}
// The runtime wire form: whole-ramp settings ride as the gradient item's attributes in its containing list,
// where the Fill kernel, chain setter nodes, and renderers read and write them
impl From<GradientRamp> for Item<Gradient> {
fn from(ramp: GradientRamp) -> Self {
let mut item = Item::new_from_element(Gradient::from(ramp.stops));
if !ramp.spread_method.is_default() {
item.set_attribute(ATTR_SPREAD_METHOD, ramp.spread_method);
}
item
}
}
impl From<&Item<Gradient>> for GradientRamp {
fn from(item: &Item<Gradient>) -> Self {
Self {
stops: item.element().into(),
spread_method: item.attribute_cloned_or_default(ATTR_SPREAD_METHOD),
}
}
}
impl From<&GradientRamp> for GradientStops<SRGBA8> {
fn from(ramp: &GradientRamp) -> Self {
Self {
@@ -158,19 +188,28 @@ impl From<&GradientStops<SRGBA8>> for GradientRamp {
impl From<&GradientRamp> for GradientRamp<SRGBA8> {
fn from(ramp: &GradientRamp) -> Self {
Self { stops: ramp.into() }
Self {
stops: ramp.into(),
spread_method: ramp.spread_method,
}
}
}
impl From<&Gradient> for GradientRamp<SRGBA8> {
fn from(gradient: &Gradient) -> Self {
Self { stops: gradient.into() }
Self {
stops: gradient.into(),
spread_method: Default::default(),
}
}
}
impl From<&GradientRamp<SRGBA8>> for GradientRamp {
fn from(ramp: &GradientRamp<SRGBA8>) -> Self {
Self::from(&ramp.stops)
Self {
spread_method: ramp.spread_method,
..Self::from(&ramp.stops)
}
}
}
@@ -747,9 +786,12 @@ impl Gradient {
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[widget(Radio)]
pub enum GradientSpreadMethod {
/// Extends the end colors outward.
#[default]
Pad,
/// Loops the gradient by mirroring back-and-forth.
Reflect,
/// Loops the gradient as copies of itself.
Repeat,
// TODO: Add a "Clear" variant that returns transparent black outside the gradient's range
}
@@ -762,6 +804,10 @@ impl GradientSpreadMethod {
GradientSpreadMethod::Repeat => "repeat",
}
}
pub fn is_default(&self) -> bool {
*self == Self::default()
}
}
/// Rebuild the y-axis so its (parallel, perpendicular) components in the x-axis-aligned frame stay constant, both
@@ -859,6 +905,44 @@ mod tests {
assert_eq!(serde_json::from_str::<GradientRamp>(&json).unwrap(), explicit);
}
#[test]
fn spread_method_serializes_only_when_not_default() {
let default_spread = GradientRamp::from(Gradient::from(vec![Color::BLACK, Color::WHITE]));
let json = serde_json::to_string(&default_spread).unwrap();
assert!(!json.contains("spread_method"), "the default Pad spread method must not serialize: {json}");
assert_eq!(serde_json::from_str::<GradientRamp>(&json).unwrap(), default_spread);
let repeating = GradientRamp {
spread_method: GradientSpreadMethod::Repeat,
..default_spread.clone()
};
let json = serde_json::to_string(&repeating).unwrap();
assert!(json.contains(r#""spread_method":"Repeat""#), "a non-default spread method must serialize: {json}");
assert_eq!(serde_json::from_str::<GradientRamp>(&json).unwrap(), repeating);
}
#[test]
fn spread_method_round_trips_through_the_item_attribute() {
let ramp = GradientRamp {
spread_method: GradientSpreadMethod::Repeat,
..GradientRamp::from(Gradient::from(vec![Color::BLACK, Color::WHITE]))
};
let item = Item::<Gradient>::from(ramp.clone());
assert_eq!(
item.attribute_cloned_or_default::<GradientSpreadMethod>(ATTR_SPREAD_METHOD),
GradientSpreadMethod::Repeat,
"the runtime item should carry the spread method as its attribute"
);
assert_eq!(GradientRamp::from(&item), ramp);
let padded = Item::<Gradient>::from(GradientRamp::from(Gradient::from(vec![Color::BLACK, Color::WHITE])));
assert!(
padded.attribute::<GradientSpreadMethod>(ATTR_SPREAD_METHOD).is_none(),
"the default Pad must stay absent rather than materialize"
);
}
#[test]
fn gradient_ui_write_back_elides_default_attributes() {
let mut gradient = Gradient::from(vec![Color::BLACK, Color::WHITE, Color::RED]);

View File

@@ -27,6 +27,8 @@ use kurbo::{Affine, BezPath, DEFAULT_ACCURACY, Line, ParamCurve, ParamCurveArcle
use rand::{Rng, SeedableRng};
use std::collections::hash_map::DefaultHasher;
use std::collections::{HashMap, HashSet};
use vector_types::ATTR_GRADIENT_TYPE;
use vector_types::GradientType;
use vector_types::gradient::{build_transform_with_y_preservation, initial_gradient_transform_for_bounding_box};
use vector_types::subpath::{BezierHandles, ManipulatorGroup};
use vector_types::vector::algorithms::bezpath_algorithms::{self, TValue, eval_pathseg_euclidean, evaluate_bezpath, split_bezpath, tangent_on_bezpath};
@@ -40,8 +42,6 @@ use vector_types::vector::misc::{
use vector_types::vector::style::{DashPattern, Gradient, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
use vector_types::vector::{FillId, PointId, RegionId, SegmentDomain, SegmentId, StrokeId, VectorExt};
use vector_types::vector::{PointDomain, RegionDomain};
use vector_types::{ATTR_GRADIENT_TYPE, ATTR_SPREAD_METHOD};
use vector_types::{GradientSpreadMethod, GradientType};
/// The gradient color for one assign-colors position, replaying the
/// randomized draws up to it.
@@ -276,9 +276,8 @@ fn park_paint<'e>(arena: &'e core_types::arena::Arena, paint: List<Graphic<'stat
/// The gradient defaulting the legacy fill performed, applied to the nested
/// stops list the paint table wraps.
fn default_gradient_paint(paint: &mut List<Graphic>, bounds: Option<[DVec2; 2]>, gradient_type: GradientType, spread_method: GradientSpreadMethod, transform: Option<DAffine2>) {
fn default_gradient_paint(paint: &mut List<Graphic>, bounds: Option<[DVec2; 2]>, gradient_type: GradientType, transform: Option<DAffine2>) {
let has_type = paint.iter_attribute_values::<GradientType>(ATTR_GRADIENT_TYPE).is_some();
let has_spread = paint.iter_attribute_values::<GradientSpreadMethod>(ATTR_SPREAD_METHOD).is_some();
let has_transform = paint.iter_attribute_values::<DAffine2>(ATTR_TRANSFORM).is_some();
for index in 0..paint.len() {
if !matches!(paint.element(index), Some(Graphic::Gradient(_))) {
@@ -287,9 +286,6 @@ fn default_gradient_paint(paint: &mut List<Graphic>, bounds: Option<[DVec2; 2]>,
if !has_type {
paint.set_attribute(ATTR_GRADIENT_TYPE, index, gradient_type);
}
if !has_spread {
paint.set_attribute(ATTR_SPREAD_METHOD, index, spread_method);
}
if !has_transform {
let transform = transform.unwrap_or_else(|| {
// Nudge a degenerate axis so the gradient transform stays invertible, matching the editor's `nonzero_bounding_box`
@@ -326,12 +322,11 @@ fn fill<'e>(
_backup_color: IList<Color>,
#[default(Color::BLACK, Color::WHITE)] _backup_gradient: IList<Gradient>,
_gradient_type: GradientType,
_spread_method: GradientSpreadMethod,
_has_transform: bool,
_transform: DAffine2,
) -> Result<(Vector, Attr<'e, Fill>), Interrupt> {
let mut paint = paint_table(fill);
default_gradient_paint(&mut paint, element.bounding_box(), _gradient_type, _spread_method, _has_transform.then_some(_transform));
default_gradient_paint(&mut paint, element.bounding_box(), _gradient_type, _has_transform.then_some(_transform));
let parked = park_paint(ctx.arena(), paint)?;
Ok((element, Attr(Some(parked))))
}
@@ -347,7 +342,6 @@ fn fill_graphic_leveled<'e>(
_backup_color: IList<Color>,
#[default(Color::BLACK, Color::WHITE)] _backup_gradient: IList<Gradient>,
_gradient_type: GradientType,
_spread_method: GradientSpreadMethod,
_has_transform: bool,
_transform: DAffine2,
) -> Result<(Graphic<'static>, Attr<'e, Fill>), Interrupt> {
@@ -356,7 +350,7 @@ fn fill_graphic_leveled<'e>(
_ => None,
};
let mut paint = paint_table(fill);
default_gradient_paint(&mut paint, bounds, _gradient_type, _spread_method, _has_transform.then_some(_transform));
default_gradient_paint(&mut paint, bounds, _gradient_type, _has_transform.then_some(_transform));
let parked = park_paint(ctx.arena(), paint)?;
Ok((element, Attr(Some(parked))))
}