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
This commit is contained in:
Keavon Chambers
2026-09-16 23:53:38 -07:00
committed by GitHub
parent eb4a122322
commit 3f1351e3b9
8 changed files with 44 additions and 88 deletions

View File

@@ -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);

View File

@@ -193,11 +193,11 @@ pub struct ColorInput {
// Content
#[widget_builder(constructor)]
pub value: FillChoice<SRGBA8>,
/// 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::<SRGBA8>::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::<SRGBA8>::None`, in which case the frontend uses its "none" fallback styling.
#[serde(rename = "swatchSamples")]
#[widget_builder(skip)]
pub chosen_gradient: Option<String>,
pub swatch_samples: Vec<GradientSample>,
#[serde(rename = "allowNone")]
#[derivative(Default(value = "true"))]
pub allow_none: bool,

View File

@@ -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<SliderSample>,
pub track_samples: Vec<GradientSample>,
/// 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,

View File

@@ -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);
}

View File

@@ -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<SRGBA8>; 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<SRGBA8>;
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}
>
<button style:--chosen-gradient={chosenGradient} style:--outline-amount={outlineFactor} on:click={() => (open = true)} tabindex="0" data-floating-menu-spawner></button>
<button style:--outline-amount={outlineFactor} on:click={() => (open = true)} tabindex="0" data-floating-menu-spawner>
{#if swatchSamples.length > 0}
<svg class="swatch" xmlns="http://www.w3.org/2000/svg">
<linearGradient id={gradientId} x1="0" y1="0" x2="1" y2="0">
{#each swatchSamples as sample}
<stop offset={sample.position} stop-color={sample.color} stop-opacity={sample.alpha} />
{/each}
</linearGradient>
<rect width="100%" height="100%" fill={`url(#${gradientId})`} />
</svg>
{/if}
</button>
<ColorPicker
{open}
{disabled}
@@ -85,14 +99,12 @@
overflow: hidden;
position: relative;
&::before {
content: "";
> .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 {

View File

@@ -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;

View File

@@ -90,13 +90,6 @@ impl From<&GradientStops<SRGBA8>> for Gradient {
}
}
impl GradientStops<SRGBA8> {
/// 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, "<stop offset='{}' stop-color='#{}'", (position * 1e4).round() / 1e4, srgba.to_rgb_hex());
if srgba.alpha < 255 {
let _ = write!(stops, " stop-opacity='{}'", (color.a() as f64 * 1000.).round() / 1000.);
}
stops.push_str("/>");
}
// A sizeless SVG stretches to fill the CSS background area; the encoding covers the URI-hostile characters
let svg = format!("<svg xmlns='http://www.w3.org/2000/svg'><linearGradient id='g' x1='0' y1='0' x2='1' y2='0'>{stops}</linearGradient><rect width='100%' height='100%' fill='url(#g)'/></svg>");
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]);

View File

@@ -66,21 +66,6 @@ impl<C> FillChoice<C> {
}
}
impl FillChoice<SRGBA8> {
/// 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<String> {
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))]