mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-10-02 07:38:12 +08:00
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:
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user