From 3f1351e3b9886b8ef0f5899d46b0e4ac3a166bd3 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 16 Sep 2026 23:53:38 -0700 Subject: [PATCH] Make the ColorInput's gradient rendering strategy to use that of SliderInput (#4546) Make the ColorInput's gradient rendering strategy to use that of SliderInput, fixing a flicker in Firefox when editing gradients --- .../messages/layout/layout_message_handler.rs | 12 ++++- .../utility_types/widgets/button_widgets.rs | 8 +-- .../utility_types/widgets/input_widgets.rs | 10 ++-- .../tool/tool_messages/gradient_tool.rs | 2 +- .../widgets/inputs/ColorInput.svelte | 32 ++++++++---- .../widgets/inputs/SliderInput.svelte | 4 +- .../libraries/vector-types/src/gradient.rs | 49 ------------------- .../vector-types/src/vector/style.rs | 15 ------ 8 files changed, 44 insertions(+), 88 deletions(-) diff --git a/editor/src/messages/layout/layout_message_handler.rs b/editor/src/messages/layout/layout_message_handler.rs index 8e0746b650..7760ee61ec 100644 --- a/editor/src/messages/layout/layout_message_handler.rs +++ b/editor/src/messages/layout/layout_message_handler.rs @@ -546,7 +546,15 @@ fn populate_computed_display_fields(layout: &mut Layout) { for instance in layout.iter_mut() { match &mut *instance.widget { Widget::ColorInput(color_input) => { - color_input.chosen_gradient = color_input.value.to_css_background_image(); + color_input.swatch_samples = match &color_input.value { + FillChoice::None => Vec::new(), + FillChoice::Solid(color) => vec![GradientSample::new(0., graphene_std::Color::from(*color))], + FillChoice::Gradient(ramp) => graphene_std::vector::style::Gradient::from(&ramp.stops) + .interpolated_samples_or_black(ramp.into()) + .into_iter() + .map(|(position, color, _)| GradientSample::new(position, color)) + .collect(), + }; } Widget::TransferCurveInput(curve_input) => { const SAMPLE_COUNT: usize = 128; @@ -578,7 +586,7 @@ fn populate_computed_display_fields(layout: &mut Layout) { slider_input.track_samples = track_gradient .interpolated_samples_or_black(settings) .into_iter() - .map(|(position, color, _)| SliderSample::new(position, color)) + .map(|(position, color, _)| GradientSample::new(position, color)) .collect(); // The end caps sample the track's boundary colors, which a cyclic wrap makes the wrapped interval's boundary-crossing color rather than the outermost stops' let track_evaluator = track_gradient.evaluator(settings); diff --git a/editor/src/messages/layout/utility_types/widgets/button_widgets.rs b/editor/src/messages/layout/utility_types/widgets/button_widgets.rs index 618c90cc6d..7c7cce3125 100644 --- a/editor/src/messages/layout/utility_types/widgets/button_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/button_widgets.rs @@ -193,11 +193,11 @@ pub struct ColorInput { // Content #[widget_builder(constructor)] pub value: FillChoice, - /// CSS `linear-gradient(...)` (or solid-color stand-in) for the swatch's `background-image`. Auto-populated from `value` at layout-send time. - /// `None` when `value` is `FillChoice::::None`, in which case the frontend uses its "none" fallback styling. - #[serde(rename = "chosenGradient")] + /// Straight-alpha color samples drawn by the frontend as the stops of an SVG gradient filling the swatch. Auto-populated from `value` at layout-send time. + /// Empty when `value` is `FillChoice::::None`, in which case the frontend uses its "none" fallback styling. + #[serde(rename = "swatchSamples")] #[widget_builder(skip)] - pub chosen_gradient: Option, + pub swatch_samples: Vec, #[serde(rename = "allowNone")] #[derivative(Default(value = "true"))] pub allow_none: bool, diff --git a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs index 55879b7fd6..6463cf4f3c 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -641,10 +641,10 @@ pub struct SliderInput { /// The path the track's stops interpolate along, used to bake `track_samples` and by the frontend to suppress the midpoint diamonds when stepped. #[serde(rename = "trackInterpolation")] pub track_interpolation: GradientInterpolation, - /// Straight-alpha samples the frontend draws as the stops of an SVG gradient filling the track strip. Auto-populated from `track` at layout-send time. + /// Straight-alpha color samples drawn by the frontend as the stops of an SVG gradient filling the track strip. Auto-populated from `track` at layout-send time. #[serde(rename = "trackSamples")] #[widget_builder(skip)] - pub track_samples: Vec, + pub track_samples: Vec, /// Hex string for the track strip's leftmost solid-color end-cap. Auto-populated by evaluating `track` at position 0. #[serde(rename = "trackStartCSS")] #[widget_builder(skip)] @@ -748,8 +748,8 @@ impl SliderMarker { #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct SliderSample { - /// Position (0..1) of the sample along the slider track, drawn as the SVG stop's `offset`. +pub struct GradientSample { + /// Position (0..1) of the sample along the gradient, drawn as the SVG stop's `offset`. position: f64, /// `#rrggbb` hex of the sample's color, drawn as the SVG stop's `stop-color`. color: String, @@ -757,7 +757,7 @@ pub struct SliderSample { alpha: f32, } -impl SliderSample { +impl GradientSample { pub fn new(position: f64, color: Color) -> Self { Self { position, diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index a74b08e7f7..eb81c3f470 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -1975,7 +1975,7 @@ fn apply_stops_update(data: &mut GradientToolData, context: &mut ToolActionMessa } responses.add(PropertiesPanelMessage::Refresh); - // Refresh the tool options so the swatch's `chosen_gradient` (precomputed CSS string) updates live as the user edits stops in the picker. + // Refresh the tool options so the swatch's `swatch_samples` update live as the user edits stops in the picker. responses.add(ToolMessage::RefreshToolOptions); } diff --git a/frontend/src/components/widgets/inputs/ColorInput.svelte b/frontend/src/components/widgets/inputs/ColorInput.svelte index 625ade1bec..5ad5c7a24c 100644 --- a/frontend/src/components/widgets/inputs/ColorInput.svelte +++ b/frontend/src/components/widgets/inputs/ColorInput.svelte @@ -3,13 +3,16 @@ import ColorPicker from "/src/components/floating-menus/ColorPicker.svelte"; import LayoutCol from "/src/components/layout/LayoutCol.svelte"; import { contrastingOutlineFactor, fillChoiceColor, fillChoiceGradient } from "/src/utility-functions/colors"; - import type { FillChoice, MenuDirection, ActionShortcut, SRGBA8 } from "/wrapper/pkg/graphite_wasm_wrapper"; + import type { FillChoice, GradientSample, MenuDirection, ActionShortcut, SRGBA8 } from "/wrapper/pkg/graphite_wasm_wrapper"; const dispatch = createEventDispatcher<{ value: FillChoice; startHistoryTransaction: undefined }>(); + // Document-unique `id` for this instance's SVG gradient, referenced by its `url(#...)` + const gradientId = `color-input-gradient-${String(Math.random()).substring(2)}`; + // Content export let value: FillChoice; - export let chosenGradient: string | undefined = undefined; + export let swatchSamples: GradientSample[] = []; export let allowNone = false; // export let allowTransparency = false; // TODO: Implement export let menuDirection: MenuDirection = "Bottom"; @@ -46,7 +49,18 @@ {tooltipDescription} {tooltipShortcut} > - + .swatch { position: absolute; top: 0; - bottom: 0; left: 0; - right: 0; - background: var(--chosen-gradient); + width: 100%; + height: 100%; } .text-label { @@ -150,8 +162,8 @@ background: var(--color-e-nearwhite); background-image: none; - &::before { - background: var(--color-e-nearwhite); + > .swatch { + display: none; } &::after { diff --git a/frontend/src/components/widgets/inputs/SliderInput.svelte b/frontend/src/components/widgets/inputs/SliderInput.svelte index 6bf052a079..a171f3421b 100644 --- a/frontend/src/components/widgets/inputs/SliderInput.svelte +++ b/frontend/src/components/widgets/inputs/SliderInput.svelte @@ -3,7 +3,7 @@ import { preventEscapeClosingParentFloatingMenu } from "/src/components/layout/FloatingMenu.svelte"; import LayoutCol from "/src/components/layout/LayoutCol.svelte"; import LayoutRow from "/src/components/layout/LayoutRow.svelte"; - import type { GradientInterpolation, SliderInputUpdate, SliderMarker, SliderSample } from "/wrapper/pkg/graphite_wasm_wrapper"; + import type { GradientInterpolation, GradientSample, SliderInputUpdate, SliderMarker } from "/wrapper/pkg/graphite_wasm_wrapper"; const BUTTON_LEFT = 0; const BUTTON_RIGHT = 2; @@ -13,7 +13,7 @@ // Document-unique `id` for this instance's SVG gradient, referenced by its `url(#...)` const gradientId = `slider-input-gradient-${String(Math.random()).substring(2)}`; - export let trackSamples: SliderSample[]; + export let trackSamples: GradientSample[]; export let trackStartCSS: string; export let trackEndCSS: string; export let trackCyclic = false; diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index 5507aa5e2d..5e08dbf3ad 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -90,13 +90,6 @@ impl From<&GradientStops> for Gradient { } } -impl GradientStops { - /// CSS `background-image` value drawing the stops as an SVG data URI, keeping straight-alpha interpolation. - pub fn to_svg_background_image(&self, settings: GradientSettings) -> String { - Gradient::from(self).to_svg_background_image(settings) - } -} - /// The serialized exchange form of a gradient: its stops, with whole-ramp settings as sibling fields serialized /// only when non-default. The space is the exception: it always serializes, so its absence marks a ramp /// from before the field existed, which deserializes as the gamma those documents rendered with. @@ -1440,28 +1433,6 @@ impl Gradient { if samples.is_empty() { vec![(0., Color::BLACK, None)] } else { samples } } - /// Build a CSS `background-image` value embedding the gradient as an SVG data URI, sampling the midpoint curves, color - /// space, and spline. SVG interpolates its stops with straight alpha, matching the canvas renderers, where a CSS - /// `linear-gradient` interpolates premultiplied and would hide the pull a transparent stop's RGB exerts on the render. - pub fn to_svg_background_image(&self, settings: GradientSettings) -> String { - use std::fmt::Write; - - let mut stops = String::new(); - for (position, color, _) in self.interpolated_samples_or_black(settings) { - let srgba = SRGBA8::from(color); - let _ = write!(stops, ""); - } - - // A sizeless SVG stretches to fill the CSS background area; the encoding covers the URI-hostile characters - let svg = format!("{stops}"); - let encoded = svg.replace('%', "%25").replace('#', "%23").replace('<', "%3C").replace('>', "%3E"); - format!("url(\"data:image/svg+xml,{encoded}\")") - } - /// Produce a set of linearly-interpolated color samples that approximate the gradient's true curve. /// /// Each sample is `(position, color, original_midpoint)` where `original_midpoint` is `Some(f64)` with the corresponding @@ -2383,26 +2354,6 @@ mod tests { } } - #[test] - fn svg_background_image_percent_encodes_and_keeps_straight_alpha_stops() { - let mut gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]); - gradient.set_color(1, Color::from_rgbaf32_unchecked(1., 1., 1., 0.5)); - - let image = gradient.to_svg_background_image(GradientSettings::default()); - - assert!(image.starts_with("url(\"data:image/svg+xml,"), "the value should be an SVG data URI: {image}"); - assert!(image.contains("stop-opacity='0.5'"), "a transparent stop should emit its straight alpha: {image}"); - assert!(!image.contains(['#', '<', '>']), "URI-hostile characters should be percent-encoded: {image}"); - } - - #[test] - fn svg_background_image_paints_a_stopless_gradient_black() { - let image = Gradient::from(Vec::new()).to_svg_background_image(GradientSettings::default()); - - // The hex color's `#` arrives percent-encoded - assert!(image.contains("stop-color='%23000000'"), "a gradient with no stops should paint black rather than nothing: {image}"); - } - #[test] fn clear_spread_evaluates_to_transparency_outside_the_unit_range() { let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]); diff --git a/node-graph/libraries/vector-types/src/vector/style.rs b/node-graph/libraries/vector-types/src/vector/style.rs index d1b5b51165..e22b68d54a 100644 --- a/node-graph/libraries/vector-types/src/vector/style.rs +++ b/node-graph/libraries/vector-types/src/vector/style.rs @@ -66,21 +66,6 @@ impl FillChoice { } } -impl FillChoice { - /// Build a CSS `background-image` string representing this fill, or `None` if the fill is [`FillChoice::None`]. - /// Solid colors become a degenerate gradient between the same color so the CSS variable can always be assigned to a `background-image`. - pub fn to_css_background_image(&self) -> Option { - match self { - Self::None => None, - Self::Solid(srgba) => { - let hex = srgba.to_rgba_hex(); - Some(format!("linear-gradient(#{hex}, #{hex})")) - } - Self::Gradient(ramp) => Some(ramp.stops.to_svg_background_image(ramp.into())), - } - } -} - /// The stroke (outline) style of an SVG element. #[repr(C)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))]