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
@@ -167,6 +167,14 @@ impl LayoutMessageHandler {
};
responses.add(callback_message);
}
Widget::ColorComparisonInput(color_comparison_input) => {
let callback_message = match action {
WidgetValueAction::Commit => (color_comparison_input.on_commit.callback)(&()),
WidgetValueAction::Update => (color_comparison_input.on_update.callback)(&()),
};
responses.add(callback_message);
}
Widget::ColorInput(color_button) => {
let callback_message = match action {
WidgetValueAction::Commit => (color_button.on_commit.callback)(&()),
@@ -182,6 +190,20 @@ impl LayoutMessageHandler {
responses.add(callback_message);
}
Widget::ColorPresetsInput(color_presets_input) => {
let callback_message = match action {
WidgetValueAction::Commit => (color_presets_input.on_commit.callback)(&()),
WidgetValueAction::Update => {
let Ok(update) = serde_json::from_value::<ColorPresetsInputUpdate>(value) else {
warn!("ColorPresetsInput update was not able to be parsed as ColorPresetsInputUpdate");
return;
};
(color_presets_input.on_update.callback)(&update)
}
};
responses.add(callback_message);
}
Widget::CurveInput(curve_input) => {
let callback_message = match action {
WidgetValueAction::Commit => (curve_input.on_commit.callback)(&()),
@@ -226,6 +248,25 @@ impl LayoutMessageHandler {
responses.add(callback_message);
}
Widget::SpectrumInput(spectrum_input) => {
let callback_message = match action {
WidgetValueAction::Commit => (spectrum_input.on_commit.callback)(&()),
WidgetValueAction::Update => {
let Ok(update) = serde_json::from_value::<SpectrumInputUpdate>(value) else {
warn!("SpectrumInput update was not able to be parsed as SpectrumInputUpdate");
return;
};
// Don't mutate the stored widget here: leaving its old values lets the layout diff detect a change
// when the new layout is rebuilt with the updated state. Otherwise the frontend's stored layout
// keeps stale values for `activeMarkerIndex`, etc., and any other widget's diff (e.g. the position
// NumberInput) will trigger Svelte to re-spread those stale props onto SpectrumInput, clobbering
// its local `activeMarkerIndex` and making subsequent drags target the wrong stop.
(spectrum_input.on_update.callback)(&update)
}
};
responses.add(callback_message);
}
Widget::IconButton(icon_button) => {
let callback_message = match action {
WidgetValueAction::Commit => (icon_button.on_commit.callback)(&()),
@@ -383,6 +424,23 @@ impl LayoutMessageHandler {
responses.add(callback_message);
}
Widget::TextLabel(_) => {}
Widget::VisualColorPickersInput(visual_color_pickers_input) => {
let callback_message = match action {
WidgetValueAction::Commit => (visual_color_pickers_input.on_commit.callback)(&()),
WidgetValueAction::Update => {
let Ok(update) = serde_json::from_value::<VisualColorPickersInputUpdate>(value) else {
warn!("VisualColorPickersInput update was not able to be parsed as VisualColorPickersInputUpdate");
return;
};
// Don't mutate the stored widget here: leaving its old values lets the layout diff detect a change
// when the new layout is rebuilt with the updated state, so the visual indicators (selection circle,
// hue/alpha needles, saturation-val ue gradient background) actually re-render after a drag.
(visual_color_pickers_input.on_update.callback)(&update)
}
};
responses.add(callback_message);
}
Widget::WorkingColorsInput(_) => {}
};
}
@@ -395,14 +453,17 @@ impl LayoutMessageHandler {
responses: &mut VecDeque<Message>,
action_input_mapping: &impl Fn(&MessageDiscriminant) -> Option<KeysGroup>,
) {
// Step 1: Collect CheckboxId mappings from new layout
// Collect CheckboxId mappings from new layout
let mut checkbox_map = HashMap::new();
new_layout.collect_checkbox_ids(layout_target, &mut Vec::new(), &mut checkbox_map);
// Step 2: Replace all IDs in new layout with deterministic ones
// Replace all IDs in new layout with deterministic ones
new_layout.replace_widget_ids(layout_target, &mut Vec::new(), &checkbox_map);
// Step 3: Diff with deterministic IDs
// Populate computed display fields on widgets that need derived values
populate_computed_display_fields(&mut new_layout);
// Diff with deterministic IDs
let mut widget_diffs = Vec::new();
self.layouts[layout_target as usize].diff(new_layout, &mut Vec::new(), &mut widget_diffs);
@@ -440,3 +501,38 @@ enum WidgetValueAction {
Commit,
Update,
}
/// Walk all widgets in the layout and populate computed display fields (e.g., precomputed CSS gradient strings) so the frontend can render them without making Wasm round-trip calls. Mutates fields in place.
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();
}
Widget::SpectrumInput(spectrum_input) => {
spectrum_input.track_css = spectrum_input.track.to_css_linear_gradient();
spectrum_input.track_start_css = spectrum_input
.track
.color
.first()
.map(|color| format!("#{}", color.to_rgba_hex_srgb_from_gamma()))
.unwrap_or_else(|| "black".to_string());
spectrum_input.track_end_css = spectrum_input
.track
.color
.last()
.map(|color| format!("#{}", color.to_rgba_hex_srgb_from_gamma()))
.unwrap_or_else(|| "black".to_string());
}
Widget::ColorComparisonInput(comparison) => {
use graphene_std::Color;
let contrasting = |color: Option<Color>| format!("#{}", color.map_or(Color::BLACK, |color| color.contrasting_text_color_from_gamma()).to_rgba_hex_srgb_from_gamma());
comparison.new_color_css = comparison.new_color.map(|color| format!("#{}", color.to_rgba_hex_srgb_from_gamma())).unwrap_or_default();
comparison.new_color_contrasting = contrasting(comparison.new_color);
comparison.old_color_css = comparison.old_color.map(|color| format!("#{}", color.to_rgba_hex_srgb_from_gamma())).unwrap_or_default();
comparison.old_color_contrasting = contrasting(comparison.old_color);
}
_ => {}
}
}
}
@@ -44,6 +44,10 @@ macro_rules! define_layout_target {
};
}
define_layout_target!(
/// Left column of the color picker popover, containing the visual H/S/V/A sliders and (optionally) the gradient editor.
ColorPickerPickersAndGradient,
/// Right column of the color picker popover, containing the new/old color comparison swatch, hex/RGB/HSV/alpha numeric inputs, and color preset buttons.
ColorPickerDetails,
/// The Data panel visualizes the output data flowing through the selected node in the graph.
DataPanel,
/// Contains the action buttons at the bottom of the dialog. Must be shown with the `FrontendMessage::DisplayDialog` message.
@@ -443,7 +447,11 @@ impl LayoutGroup {
| Widget::ShortcutLabel(_)
| Widget::WorkingColorsInput(_)
| Widget::NodeCatalog(_)
| Widget::ParameterExposeButton(_) => continue,
| Widget::ParameterExposeButton(_)
| Widget::ColorComparisonInput(_)
| Widget::ColorPresetsInput(_)
| Widget::SpectrumInput(_)
| Widget::VisualColorPickersInput(_) => continue,
};
if val.is_empty() {
val.clone_from(&description);
@@ -758,7 +766,9 @@ impl<T> Default for WidgetCallback<T> {
pub enum Widget {
BreadcrumbTrailButtons(BreadcrumbTrailButtons),
CheckboxInput(CheckboxInput),
ColorComparisonInput(ColorComparisonInput),
ColorInput(ColorInput),
ColorPresetsInput(ColorPresetsInput),
CurveInput(CurveInput),
DropdownInput(DropdownInput),
IconButton(IconButton),
@@ -773,10 +783,12 @@ pub enum Widget {
PopoverButton(PopoverButton),
RadioInput(RadioInput),
Separator(Separator),
SpectrumInput(SpectrumInput),
TextAreaInput(TextAreaInput),
TextButton(TextButton),
TextInput(TextInput),
TextLabel(TextLabel),
VisualColorPickersInput(VisualColorPickersInput),
WorkingColorsInput(WorkingColorsInput),
}
@@ -834,7 +846,11 @@ impl DiffUpdate {
| Widget::TextAreaInput(_)
| Widget::TextInput(_)
| Widget::TextLabel(_)
| Widget::WorkingColorsInput(_) => None,
| Widget::WorkingColorsInput(_)
| Widget::ColorComparisonInput(_)
| Widget::ColorPresetsInput(_)
| Widget::SpectrumInput(_)
| Widget::VisualColorPickersInput(_) => None,
};
// Convert `ActionShortcut::Action` to `ActionShortcut::Shortcut`
@@ -190,6 +190,11 @@ pub struct ColorInput {
/// WARNING: The colors are gamma, not linear!
#[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")]
#[widget_builder(skip)]
pub chosen_gradient: Option<String>,
#[serde(rename = "allowNone")]
#[derivative(Default(value = "true"))]
pub allow_none: bool,
@@ -6,6 +6,7 @@ use derivative::*;
use graphene_std::Color;
use graphene_std::raster::curve::Curve;
use graphene_std::transform::ReferencePoint;
use graphene_std::vector::style::{FillChoice, GradientStops};
use graphite_proc_macros::WidgetBuilder;
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
@@ -444,6 +445,206 @@ pub struct CurveInput {
pub on_commit: WidgetCallback<()>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Default, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder)]
#[derivative(Debug, PartialEq)]
pub struct VisualColorPickersInput {
// Content
pub hue: f64,
pub saturation: f64,
pub value: f64,
pub alpha: f64,
#[serde(rename = "isNone")]
pub is_none: bool,
pub disabled: bool,
// Callbacks
// `on_update` receives the raw `VisualColorPickersInputUpdate` (not the mutated widget) so the layout diffing can still detect a change between the stored layout and the rebuilt one, otherwise the selection circle and slider needles never re-render after a drag.
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<VisualColorPickersInputUpdate>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_commit: WidgetCallback<()>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct VisualColorPickersInputUpdate {
pub hue: f64,
pub saturation: f64,
pub value: f64,
pub alpha: f64,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Default, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder)]
#[derivative(Debug, PartialEq)]
pub struct ColorComparisonInput {
// Content
#[widget_builder(constructor)]
#[serde(rename = "newColor")]
pub new_color: Option<Color>,
#[widget_builder(constructor)]
#[serde(rename = "oldColor")]
pub old_color: Option<Color>,
#[serde(rename = "isNone")]
pub is_none: bool,
#[serde(rename = "oldIsNone")]
pub old_is_none: bool,
pub disabled: bool,
pub differs: bool,
#[serde(rename = "outlineAmount")]
pub outline_amount: f64,
/// Hex CSS string for the new color (with alpha if not fully opaque), or empty when `is_none`. Auto-populated from `new_color` at layout-send time.
#[serde(rename = "newColorCSS")]
#[widget_builder(skip)]
pub new_color_css: String,
/// Black or white, whichever contrasts the new color for legible label text. Auto-populated.
#[serde(rename = "newColorContrasting")]
#[widget_builder(skip)]
pub new_color_contrasting: String,
/// Hex CSS string for the old color (with alpha if not fully opaque), or empty when `old_is_none`. Auto-populated.
#[serde(rename = "oldColorCSS")]
#[widget_builder(skip)]
pub old_color_css: String,
/// Black or white, whichever contrasts the old color for legible label text. Auto-populated.
#[serde(rename = "oldColorContrasting")]
#[widget_builder(skip)]
pub old_color_contrasting: String,
// Callbacks
// The `swap` event has no payload — it just signals the user requested a swap.
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<()>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_commit: WidgetCallback<()>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Default, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder)]
#[derivative(Debug, PartialEq)]
pub struct ColorPresetsInput {
// Content
pub disabled: bool,
#[serde(rename = "showNoneOption")]
pub show_none_option: bool,
// Callbacks
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<ColorPresetsInputUpdate>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_commit: WidgetCallback<()>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum ColorPresetsInputUpdate {
Preset(FillChoice),
EyedropperColorCode(String),
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder)]
#[derivative(Debug, PartialEq, Default)]
pub struct SpectrumInput {
// Content
/// The colored gradient drawn behind the markers (display-only, caller-owned).
#[widget_builder(constructor)]
pub track: GradientStops,
/// CSS `linear-gradient(...)` string for the track strip's `background-image`. Auto-populated from `track` at layout-send time.
#[serde(rename = "trackCSS")]
#[widget_builder(skip)]
pub track_css: String,
/// Hex string for the track strip's leftmost solid-color end-cap. Auto-populated from `track`'s first stop.
#[serde(rename = "trackStartCSS")]
#[widget_builder(skip)]
pub track_start_css: String,
/// Hex string for the track strip's rightmost solid-color end-cap. Auto-populated from `track`'s last stop.
#[serde(rename = "trackEndCSS")]
#[widget_builder(skip)]
pub track_end_css: String,
/// The handles the user can drag along the track. Their handle colors are caller-owned (e.g., for a gradient editor they follow the stop colors, for a "Shadows/Midpoints/Highlights" widget they're hardcoded).
pub markers: Vec<SpectrumMarker>,
#[serde(rename = "activeMarkerIndex")]
pub active_marker_index: Option<u32>,
#[serde(rename = "activeMarkerIsMidpoint")]
pub active_marker_is_midpoint: bool,
/// Whether to render midpoint diamonds between adjacent markers (only meaningful for gradient-like uses).
#[serde(rename = "showMidpoints")]
pub show_midpoints: bool,
/// Whether clicking the track inserts a new marker at the click position.
#[serde(rename = "allowInsert")]
pub allow_insert: bool,
/// Whether right-click or pressing Delete removes a marker. The handler still has the final say on whether the deletion goes through (e.g., enforcing a minimum count).
#[serde(rename = "allowDelete")]
pub allow_delete: bool,
/// Whether dragging a marker past another reorders them. If false, the dragged marker is clamped between its neighbors.
#[serde(rename = "allowSwap")]
pub allow_swap: bool,
/// Whether the input is disabled (dimmed and read-only).
pub disabled: bool,
// Callbacks
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_update: WidgetCallback<SpectrumInputUpdate>,
#[serde(skip)]
#[derivative(Debug = "ignore", PartialEq = "ignore")]
pub on_commit: WidgetCallback<()>,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct SpectrumMarker {
/// Position (0..1) of the marker along the spectrum track.
position: f64,
/// Position (0..1) of the midpoint between this marker and the next, used only if `show_midpoints` is true. The last marker's value is ignored.
midpoint: f64,
/// CSS color string for the marker handle's fill. Set via `SpectrumMarker::new` from a [`Color`] (gamma space).
#[serde(rename = "handleColorCSS")]
handle_color_css: String,
}
impl SpectrumMarker {
pub fn new(position: f64, midpoint: f64, handle_color: Color) -> Self {
let handle_color_css = format!("#{}", handle_color.to_rgba_hex_srgb_from_gamma());
Self { position, midpoint, handle_color_css }
}
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum SpectrumInputUpdate {
MoveMarker {
index: u32,
position: f64,
},
MoveMidpoint {
index: u32,
position: f64,
},
InsertMarker {
position: f64,
},
DeleteMarker {
index: u32,
},
ResetMidpoint {
index: u32,
},
ActiveMarker {
#[serde(rename = "activeMarkerIndex")]
active_marker_index: Option<u32>,
#[serde(rename = "activeMarkerIsMidpoint")]
active_marker_is_midpoint: bool,
},
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Default, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder)]
#[derivative(Debug, PartialEq)]