Port the color picker popover to a Rust-defined layout (#4102)

* Break out VisualColorPickersInput.svelte

* Break out ColorComparisonInput.svelte and ColorPresetsInput.svelte

* Add backend definitions and plumbing for the 4 new widgets

* Port the ColorPicker.svelte layout and business logic to Rust

* Port more ColorComparisonInput.svelte logic to Rust

* Port more SpectrumInput.svelte logic to Rust

* Port more frontend logic to Rust

* Code review

* Code review

* Fix some CSS
This commit is contained in:
Keavon Chambers
2026-05-05 02:47:53 -07:00
committed by GitHub
parent 62203cb171
commit e59612c4ce
31 changed files with 2260 additions and 1333 deletions

View File

@@ -188,7 +188,7 @@ pub enum NodeInput {
/// Input that is extracted from the parent scopes the node resides in. The string argument is the key.
Scope(Cow<'static, str>),
/// Input that is extracted from the parent scopes the node resides in. The string argument is the key.
/// Input that is replaced at graph compilation with introspective metadata about this node's location.
Reflection(DocumentNodeMetadata),
/// A Rust source code string. Allows us to insert literal Rust code. Only used for GPU compilation.

View File

@@ -220,7 +220,7 @@ impl Pixel for Luma {}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "std", derive(dyn_any::DynAny, serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "std", derive(graphene_hash::CacheHash))]
#[derive(Debug, Default, Clone, Copy, Pod, Zeroable, BufferStruct)]
#[derive(Debug, Default, Clone, Copy, PartialEq, Pod, Zeroable, BufferStruct)]
pub struct Color {
red: f32,
green: f32,
@@ -228,12 +228,7 @@ pub struct Color {
alpha: f32,
}
impl PartialEq for Color {
fn eq(&self, other: &Self) -> bool {
self.red == other.red && self.green == other.green && self.blue == other.blue && self.alpha == other.alpha
}
}
// `f32` channels mean `Color` doesn't qualify for a derived `Eq`, but in practice we never store NaN here, and the renderer's `HashMap<CacheHashWrapper<Image<Color>>, _>` deduplication needs `Color: Eq` to propagate up through the wrapper.
impl Eq for Color {}
impl RGB for Color {
@@ -869,6 +864,15 @@ impl Color {
)
}
/// [`Color::BLACK`] or [`Color::WHITE`], whichever gives more legible text against this color (alpha composited over white, WCAG-style luminance threshold). Use this if this [`Color`] is in gamma space.
pub fn contrasting_text_color_from_gamma(&self) -> Color {
let composited = Self::WHITE.alpha_blend(Self::from_unassociated_alpha(self.r(), self.g(), self.b(), self.a()));
let luminance = composited.to_linear_srgb().luminance_srgb();
// WCAG-derived perceptual midpoint between black and white (~0.179)
let threshold = (1.05_f32 * 0.05).sqrt() - 0.05;
if luminance > threshold { Self::BLACK } else { Self::WHITE }
}
/// Return the all components as a u8 slice, first component is red, followed by green, followed by blue, followed by alpha. Use this if the [`Color`] is in gamma space.
#[inline(always)]
pub fn to_rgba8(&self) -> [u8; 4] {

View File

@@ -197,6 +197,47 @@ impl GradientStops {
self.color.pop()
}
/// Move the stop at `index` to a new position, re-sorting the stops by position. Returns the new index of the moved stop.
pub fn move_stop(&mut self, index: usize, position: f64) -> usize {
if index >= self.position.len() {
return index;
}
self.position[index] = position;
self.sort_returning_new_index(index)
}
/// Insert a new stop at the given position, sampling the gradient at that position to determine the new stop's color.
/// The new stop's midpoint is inherited from the interval it splits (or `0.5` if inserting at the very start).
/// Returns the index where the new stop was inserted.
pub fn insert_stop(&mut self, position: f64) -> usize {
let color = self.evaluate(position);
let index = self.position.iter().position(|p| *p > position).unwrap_or(self.position.len());
let midpoint = index.checked_sub(1).and_then(|i| self.midpoint.get(i).copied()).unwrap_or(0.5);
self.position.insert(index, position);
self.midpoint.insert(index, midpoint);
self.color.insert(index, color);
index
}
/// Reset the midpoint for the interval starting at `index` to its default `0.5`.
pub fn reset_midpoint(&mut self, index: usize) {
if let Some(midpoint) = self.midpoint.get_mut(index) {
*midpoint = 0.5;
}
}
/// Sort the stops in place by position; returns the new index of the stop that was at `previous_index` before sorting.
fn sort_returning_new_index(&mut self, previous_index: usize) -> usize {
let len = self.position.len();
let mut indices: Vec<usize> = (0..len).collect();
indices.sort_by(|&a, &b| self.position[a].total_cmp(&self.position[b]));
let new_index = indices.iter().position(|&i| i == previous_index).unwrap_or(previous_index);
self.position = indices.iter().map(|&i| self.position[i]).collect();
self.midpoint = indices.iter().map(|&i| self.midpoint[i]).collect();
self.color = indices.iter().map(|&i| self.color[i]).collect();
new_index
}
pub fn evaluate(&self, t: f64) -> Color {
if self.position.is_empty() {
return Color::BLACK;
@@ -250,6 +291,24 @@ impl GradientStops {
}
}
/// Build a CSS `linear-gradient(...)` string suitable for use as a `background-image`. Samples the midpoint curves so the rendered gradient matches Graphite's interpolation rather than browser defaults.
pub fn to_css_linear_gradient(&self) -> String {
if self.position.len() <= 1 {
let hex = self.color.first().map(|c| c.to_rgba_hex_srgb_from_gamma()).unwrap_or_else(|| "000000ff".to_string());
return format!("linear-gradient(to right, #{hex} 0%, #{hex} 100%)");
}
let pieces = self
.interpolated_samples()
.into_iter()
.map(|(position, color, _)| {
let percent = ((position * 100.) * 1e2).round() / 1e2;
format!("#{} {percent}%", color.to_rgba_hex_srgb_from_gamma())
})
.collect::<Vec<_>>()
.join(", ");
format!("linear-gradient(to right, {pieces})")
}
/// Produce a set of linearly-interpolated color samples that approximate the gradient's midpoint curves.
///
/// Each sample is `(position, color, original_midpoint)` where `original_midpoint` is `Some(f64)` with the corresponding

View File

@@ -185,6 +185,18 @@ impl FillChoice {
Some(gradient)
}
/// Build a CSS `background-image` string (always a `linear-gradient(...)`) 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(color) => {
let hex = color.to_rgba_hex_srgb_from_gamma();
Some(format!("linear-gradient(#{hex}, #{hex})"))
}
Self::Gradient(stops) => Some(stops.to_css_linear_gradient()),
}
}
/// Convert this [`FillChoice`] to a [`Fill`] using the provided [`Gradient`] as a base for the positional information of the gradient.
/// If a gradient isn't provided, default gradient positional information is used in cases where the [`FillChoice`] is a [`Gradient`].
pub fn to_fill(&self, existing_gradient: Option<&Gradient>) -> Fill {