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
+1
View File
@@ -46,6 +46,7 @@ vello = { workspace = true }
base64 = { workspace = true }
spin = { workspace = true }
image = { workspace = true }
color = { workspace = true }
# Optional local dependencies
wgpu-executor = { workspace = true, optional = true }
+2
View File
@@ -21,6 +21,7 @@ pub struct DispatcherMessageHandlers {
app_window_message_handler: AppWindowMessageHandler,
broadcast_message_handler: BroadcastMessageHandler,
clipboard_message_handler: ClipboardMessageHandler,
color_picker_message_handler: ColorPickerMessageHandler,
debug_message_handler: DebugMessageHandler,
defer_message_handler: DeferMessageHandler,
dialog_message_handler: DialogMessageHandler,
@@ -164,6 +165,7 @@ impl Dispatcher {
}
Message::Broadcast(message) => self.message_handlers.broadcast_message_handler.process_message(message, &mut queue, ()),
Message::Clipboard(message) => self.message_handlers.clipboard_message_handler.process_message(message, &mut queue, ()),
Message::ColorPicker(message) => self.message_handlers.color_picker_message_handler.process_message(message, &mut queue, ()),
Message::Debug(message) => {
self.message_handlers.debug_message_handler.process_message(message, &mut queue, ());
}
@@ -0,0 +1,55 @@
use crate::messages::layout::utility_types::widgets::input_widgets::{SpectrumInputUpdate, VisualColorPickersInputUpdate};
use crate::messages::prelude::*;
use graphene_std::vector::style::FillChoice;
/// Identifies which RGB channel a numeric input change targets.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum RgbChannel {
Red,
Green,
Blue,
}
/// Identifies which HSV channel a numeric input change targets.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum HsvChannel {
Hue,
Saturation,
Value,
}
#[impl_message(Message, ColorPicker)]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum ColorPickerMessage {
/// Initialize the picker state from an external color/gradient and announce its options. Called by the frontend when a `<ColorPicker>` opens.
Open { initial_value: FillChoice, allow_none: bool, disabled: bool },
/// Clear the picker state. Called by the frontend when the popover closes.
Close,
/// Visual sat/val/hue/alpha drag updates from `VisualColorPickersInput`.
VisualUpdate { update: VisualColorPickersInputUpdate },
/// Numeric RGB channel update.
SetChannelRgb { channel: RgbChannel, value: Option<f64> },
/// Numeric HSV channel update.
SetChannelHsv { channel: HsvChannel, value: Option<f64> },
/// Alpha percentage update from the alpha slider numeric input.
SetAlphaPercent { value: Option<f64> },
/// CSS / hex color string from the hex `TextInput`.
SetHexCode { code: String },
/// Pick a preset (specific solid color or "None").
PickPreset { preset: FillChoice },
/// Color picked via the browser-native eyedropper. The string is the eyedropper's returned hex code.
EyedropperColorCode { code: String },
/// Swap the current "new" color with the captured "old" color.
SwapNewWithOld,
/// `SpectrumInput` change: marker move/insert/delete, midpoint move/reset, or active marker selection changed.
GradientUpdate { update: SpectrumInputUpdate },
/// Tell the frontend to start an undo transaction (forwarded as a `FrontendMessage` it bridges out to the picker's parent).
StartTransaction,
/// Tell the frontend to commit the in-flight undo transaction.
CommitTransaction,
}
@@ -0,0 +1,700 @@
use crate::messages::color_picker::color_picker_message::{HsvChannel, RgbChannel};
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::layout::utility_types::widgets::input_widgets::{ColorPresetsInputUpdate, SpectrumInputUpdate, SpectrumMarker, VisualColorPickersInputUpdate};
use crate::messages::prelude::*;
use color::{AlphaColor, Srgb};
use graphene_std::Color;
use graphene_std::vector::style::{FillChoice, GradientStops};
/// Bounds for a midpoint position (relative to the interval between two adjacent gradient stops).
const MIN_MIDPOINT: f64 = 0.01;
const MAX_MIDPOINT: f64 = 0.99;
#[derive(Debug, Clone, PartialEq, ExtractField)]
pub struct ColorPickerMessageHandler {
// HSV is the source of truth so the hue is preserved when the user desaturates the color (or drives the value to black) and back.
hue: f64,
saturation: f64,
value: f64,
alpha: f64,
is_none: bool,
// Snapshot of the color when the picker opened, used by the new/old comparison swatch and the swap button.
old_hue: f64,
old_saturation: f64,
old_value: f64,
old_alpha: f64,
old_is_none: bool,
// When set, the picker is editing a gradient: the visual pickers and inputs target the active stop's color.
gradient: Option<GradientStops>,
active_marker_index: Option<u32>,
active_marker_is_midpoint: bool,
allow_none: bool,
disabled: bool,
}
impl Default for ColorPickerMessageHandler {
fn default() -> Self {
Self {
hue: 0.,
saturation: 0.,
value: 0.,
alpha: 1.,
is_none: true,
old_hue: 0.,
old_saturation: 0.,
old_value: 0.,
old_alpha: 1.,
old_is_none: true,
gradient: None,
active_marker_index: None,
active_marker_is_midpoint: false,
allow_none: true,
disabled: false,
}
}
}
#[message_handler_data]
impl MessageHandler<ColorPickerMessage, ()> for ColorPickerMessageHandler {
fn process_message(&mut self, message: ColorPickerMessage, responses: &mut VecDeque<Message>, _context: ()) {
match message {
ColorPickerMessage::Open { initial_value, allow_none, disabled } => {
self.allow_none = allow_none;
self.disabled = disabled;
// Each `<ColorPicker>` Svelte instance maintains its own local layout state, but the Rust `LayoutMessageHandler` keeps a single shared layout per target. When a new picker instance opens after a previous one closed, the new instance's layout starts empty and a diff from the previously-shared state would not apply. Destroying the stored layouts here forces the next `SendLayout` to send the full layout instead of a diff.
responses.add(LayoutMessage::DestroyLayout {
layout_target: LayoutTarget::ColorPickerPickersAndGradient,
});
responses.add(LayoutMessage::DestroyLayout {
layout_target: LayoutTarget::ColorPickerDetails,
});
match initial_value {
FillChoice::None => {
self.set_new_hsva(0., 0., 0., 1., true);
self.gradient = None;
self.active_marker_index = None;
self.active_marker_is_midpoint = false;
}
FillChoice::Solid(color) => {
self.gradient = None;
self.active_marker_index = None;
self.active_marker_is_midpoint = false;
self.adopt_color(color);
}
FillChoice::Gradient(stops) => {
self.active_marker_index = Some(0);
self.active_marker_is_midpoint = false;
let first_color = stops.color.first().copied().unwrap_or(Color::BLACK);
self.gradient = Some(stops);
self.adopt_color(first_color);
}
}
self.snapshot_old();
self.send_layouts(responses);
}
ColorPickerMessage::Close => {
self.gradient = None;
self.active_marker_index = None;
self.active_marker_is_midpoint = false;
}
ColorPickerMessage::VisualUpdate { update } => {
self.hue = update.hue;
self.saturation = update.saturation;
self.value = update.value;
self.alpha = update.alpha;
self.is_none = false;
self.emit_color(responses);
self.send_layouts(responses);
}
ColorPickerMessage::SetChannelRgb { channel, value } => {
let Some(strength) = value else { return };
let Some(current) = self.current_color() else { return };
let updated = match channel {
RgbChannel::Red => Color::from_rgbaf32_unchecked((strength / 255.) as f32, current.g(), current.b(), current.a()),
RgbChannel::Green => Color::from_rgbaf32_unchecked(current.r(), (strength / 255.) as f32, current.b(), current.a()),
RgbChannel::Blue => Color::from_rgbaf32_unchecked(current.r(), current.g(), (strength / 255.) as f32, current.a()),
};
self.adopt_color(updated);
self.emit_color(responses);
self.send_layouts(responses);
}
ColorPickerMessage::SetChannelHsv { channel, value } => {
let Some(strength) = value else { return };
match channel {
HsvChannel::Hue => self.hue = strength / 360.,
HsvChannel::Saturation => self.saturation = strength / 100.,
HsvChannel::Value => self.value = strength / 100.,
}
self.is_none = false;
self.emit_color(responses);
self.send_layouts(responses);
}
ColorPickerMessage::SetAlphaPercent { value } => {
let Some(strength) = value else { return };
self.alpha = strength / 100.;
self.is_none = false;
self.emit_color(responses);
self.send_layouts(responses);
}
ColorPickerMessage::SetHexCode { code } => {
let Some(color) = parse_css_color(&code) else {
// Parse failed: re-send the layouts so the TextInput's displayed value reverts from the user's bad input
// back to the current color's hex string. The TextInput dispatch arm has already mutated the stored
// widget's `value` to the bad input, so the diff between (stored = bad) and (new = correct) sends an update.
self.send_layouts(responses);
return;
};
responses.add(FrontendMessage::ColorPickerStartHistoryTransaction);
self.adopt_color(color);
self.emit_color(responses);
self.send_layouts(responses);
}
ColorPickerMessage::PickPreset { preset } => {
responses.add(FrontendMessage::ColorPickerStartHistoryTransaction);
match preset {
FillChoice::None => {
self.set_new_hsva(0., 0., 0., 1., true);
responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoice::None });
}
FillChoice::Solid(color) => {
self.adopt_color(color);
self.emit_color(responses);
}
FillChoice::Gradient(_) => {
// The presets row only emits solid colors or "None"; the gradient case is unreachable, so safely ignore.
}
}
self.send_layouts(responses);
}
ColorPickerMessage::EyedropperColorCode { code } => {
let Some(color) = parse_css_color(&code) else { return };
responses.add(FrontendMessage::ColorPickerStartHistoryTransaction);
self.adopt_color(color);
self.emit_color(responses);
self.send_layouts(responses);
}
ColorPickerMessage::SwapNewWithOld => {
let temp = (self.hue, self.saturation, self.value, self.alpha, self.is_none);
self.set_new_hsva(self.old_hue, self.old_saturation, self.old_value, self.old_alpha, self.old_is_none);
self.set_old_hsva(temp.0, temp.1, temp.2, temp.3, temp.4);
if self.is_none {
responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoice::None });
} else {
self.emit_color(responses);
}
self.send_layouts(responses);
}
ColorPickerMessage::GradientUpdate { update } => self.apply_gradient_update(update, responses),
ColorPickerMessage::StartTransaction => {
responses.add(FrontendMessage::ColorPickerStartHistoryTransaction);
}
ColorPickerMessage::CommitTransaction => {
responses.add(FrontendMessage::ColorPickerCommitHistoryTransaction);
}
}
}
fn actions(&self) -> ActionList {
actions!(ColorPickerMessageDiscriminant;)
}
}
impl ColorPickerMessageHandler {
fn current_color(&self) -> Option<Color> {
if self.is_none {
None
} else {
Some(Color::from_hsva(self.hue as f32, self.saturation as f32, self.value as f32, self.alpha as f32))
}
}
fn old_color(&self) -> Option<Color> {
if self.old_is_none {
None
} else {
Some(Color::from_hsva(self.old_hue as f32, self.old_saturation as f32, self.old_value as f32, self.old_alpha as f32))
}
}
fn set_new_hsva(&mut self, h: f64, s: f64, v: f64, a: f64, is_none: bool) {
self.hue = h;
self.saturation = s;
self.value = v;
self.alpha = a;
self.is_none = is_none;
}
fn set_old_hsva(&mut self, h: f64, s: f64, v: f64, a: f64, is_none: bool) {
self.old_hue = h;
self.old_saturation = s;
self.old_value = v;
self.old_alpha = a;
self.old_is_none = is_none;
}
fn snapshot_old(&mut self) {
self.old_hue = self.hue;
self.old_saturation = self.saturation;
self.old_value = self.value;
self.old_alpha = self.alpha;
self.old_is_none = self.is_none;
}
/// Set HSV state from a Color, preserving hue and saturation in degenerate cases.
fn adopt_color(&mut self, color: Color) {
let [target_h, target_s, target_v] = rgb_to_hsv(color.r() as f64, color.g() as f64, color.b() as f64);
// Preserve hue: avoid jumping from 360° (top) to 0° (bottom) and don't reset hue when the color is desaturated or fully dark.
if !(target_h == 0. && self.hue == 1.) && target_s > 0. && target_v > 0. {
self.hue = target_h;
}
// Preserve saturation when value is black (saturation is meaningless on the bottom edge of the saturation-value box).
if target_v != 0. {
self.saturation = target_s;
}
self.value = target_v;
self.alpha = color.a() as f64;
self.is_none = false;
}
/// Compute the FillChoice and forward it via `FrontendMessage::ColorPickerColorChanged`. In gradient mode, the active stop's color is updated in place.
fn emit_color(&mut self, responses: &mut VecDeque<Message>) {
let Some(color) = self.current_color() else { return };
if let Some(gradient) = &mut self.gradient
&& let Some(active_index) = self.active_marker_index
&& let Some(stop_color) = gradient.color.get_mut(active_index as usize)
{
*stop_color = color;
let stops = gradient.clone();
responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoice::Gradient(stops) });
} else {
responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoice::Solid(color) });
}
}
fn send_layouts(&self, responses: &mut VecDeque<Message>) {
responses.add(LayoutMessage::SendLayout {
layout: self.pickers_and_gradient_layout(),
layout_target: LayoutTarget::ColorPickerPickersAndGradient,
});
responses.add(LayoutMessage::SendLayout {
layout: self.details_layout(),
layout_target: LayoutTarget::ColorPickerDetails,
});
}
/// Apply an incoming `SpectrumInput` intent to the gradient state and broadcast the result.
fn apply_gradient_update(&mut self, update: SpectrumInputUpdate, responses: &mut VecDeque<Message>) {
// Active marker selection is the one update that doesn't mutate the gradient
if let SpectrumInputUpdate::ActiveMarker {
active_marker_index,
active_marker_is_midpoint,
} = update
{
self.active_marker_index = active_marker_index;
self.active_marker_is_midpoint = active_marker_is_midpoint;
if let Some(index) = active_marker_index
&& let Some(gradient) = &self.gradient
&& let Some(color) = gradient.color.get(index as usize).copied()
{
self.adopt_color(color);
self.snapshot_old();
}
self.send_layouts(responses);
return;
}
let Some(mut gradient) = self.gradient.clone() else { return };
match update {
SpectrumInputUpdate::MoveMarker { index, position } => {
let new_index = gradient.move_stop(index as usize, position);
if Some(index) == self.active_marker_index {
self.active_marker_index = Some(new_index as u32);
}
}
SpectrumInputUpdate::MoveMidpoint { index, position } => {
if let Some(midpoint) = gradient.midpoint.get_mut(index as usize) {
*midpoint = position.clamp(MIN_MIDPOINT, MAX_MIDPOINT);
} else {
return;
}
}
SpectrumInputUpdate::InsertMarker { position } => {
let new_index = gradient.insert_stop(position);
self.active_marker_index = Some(new_index as u32);
self.active_marker_is_midpoint = false;
if let Some(color) = gradient.color.get(new_index).copied() {
self.adopt_color(color);
self.snapshot_old();
}
}
SpectrumInputUpdate::DeleteMarker { index } => {
// Enforce minimum stop count. The gradient editor needs at least 2 stops to remain meaningful.
if gradient.position.len() <= 2 || (index as usize) >= gradient.position.len() {
return;
}
gradient.remove(index as usize);
let new_active = (index as usize).min(gradient.position.len() - 1);
self.active_marker_index = Some(new_active as u32);
self.active_marker_is_midpoint = false;
if let Some(color) = gradient.color.get(new_active).copied() {
self.adopt_color(color);
self.snapshot_old();
}
}
SpectrumInputUpdate::ResetMidpoint { index } => {
gradient.reset_midpoint(index as usize);
}
SpectrumInputUpdate::ActiveMarker { .. } => unreachable!("handled above"),
}
self.gradient = Some(gradient.clone());
responses.add(FrontendMessage::ColorPickerColorChanged {
value: FillChoice::Gradient(gradient),
});
self.send_layouts(responses);
}
fn pickers_and_gradient_layout(&self) -> Layout {
let mut groups = Vec::new();
// Visual H/S/V/A pickers
groups.push(LayoutGroup::row(vec![
VisualColorPickersInput::default()
.hue(self.hue)
.saturation(self.saturation)
.value(self.value)
.alpha(self.alpha)
.is_none(self.is_none)
.disabled(self.disabled)
.on_update(|update: &VisualColorPickersInputUpdate| ColorPickerMessage::VisualUpdate { update: update.clone() }.into())
.on_commit(|_| ColorPickerMessage::CommitTransaction.into())
.widget_instance(),
]));
// Gradient editor (only present when the picker is in gradient mode)
if let Some(gradient) = &self.gradient {
// For gradient editing, the markers' handle colors mirror their gradient stop colors
let markers = gradient.iter().map(|stop| SpectrumMarker::new(stop.position, stop.midpoint, stop.color)).collect();
let mut row_widgets = vec![
SpectrumInput::new(gradient.clone())
.markers(markers)
.active_marker_index(self.active_marker_index)
.active_marker_is_midpoint(self.active_marker_is_midpoint)
.show_midpoints(true)
.allow_insert(!self.disabled)
.allow_delete(!self.disabled)
.allow_swap(true)
.disabled(self.disabled)
.on_update(|update: &SpectrumInputUpdate| ColorPickerMessage::GradientUpdate { update: update.clone() }.into())
.widget_instance(),
];
if let Some(active) = self.active_marker_index {
let active_index = active as usize;
let position_value = if self.active_marker_is_midpoint {
gradient.midpoint.get(active_index).copied().unwrap_or(0.)
} else {
gradient.position.get(active_index).copied().unwrap_or(0.)
};
let is_midpoint = self.active_marker_is_midpoint;
let captured_index = active;
row_widgets.push(
NumberInput::new(Some(position_value * 100.))
.disabled(self.disabled)
.display_decimal_places(0)
.min(if is_midpoint { 1. } else { 0. })
.max(if is_midpoint { 99. } else { 100. })
.unit("%")
.on_update(move |widget: &NumberInput| {
let Some(new_value) = widget.value else {
return Message::NoOp;
};
let update = if is_midpoint {
SpectrumInputUpdate::MoveMidpoint {
index: captured_index,
position: new_value / 100.,
}
} else {
SpectrumInputUpdate::MoveMarker {
index: captured_index,
position: new_value / 100.,
}
};
ColorPickerMessage::GradientUpdate { update }.into()
})
.widget_instance(),
);
}
groups.push(LayoutGroup::row(row_widgets));
}
Layout(groups)
}
fn details_layout(&self) -> Layout {
let new_color = self.current_color();
let old_color = self.old_color();
let hex_value = new_color.map(|c| color_to_hex_optional_alpha(&c)).unwrap_or_else(|| "-".to_string());
let rgb_255 = new_color.map(|c| (c.r() as f64 * 255., c.g() as f64 * 255., c.b() as f64 * 255.));
// Epsilon comparison since the picker round-trips through HSV
let differs = match (new_color, old_color) {
(Some(a), Some(b)) => {
const EPSILON: f32 = 1e-6;
(a.r() - b.r()).abs() >= EPSILON || (a.g() - b.g()).abs() >= EPSILON || (a.b() - b.b()).abs() >= EPSILON || (a.a() - b.a()).abs() >= EPSILON
}
(None, None) => false,
_ => true,
};
let outline_amount = contrasting_outline_factor(new_color).max(contrasting_outline_factor(old_color));
let mut groups = Vec::new();
// New/old comparison swatch with swap button
groups.push(LayoutGroup::row(vec![
ColorComparisonInput::new(new_color, old_color)
.is_none(self.is_none)
.old_is_none(self.old_is_none)
.disabled(self.disabled)
.differs(differs)
.outline_amount(outline_amount)
.on_update(|_: &()| ColorPickerMessage::SwapNewWithOld.into())
.widget_instance(),
]));
// Hex
groups.push(LayoutGroup::row(vec![
TextLabel::new("Hex").tooltip_label("Hex Color Code").tooltip_description(HEX_DESCRIPTION).widget_instance(),
Separator::new(SeparatorStyle::Related).widget_instance(),
TextInput::new(hex_value)
.centered(true)
.disabled(self.disabled)
.tooltip_label("Hex Color Code")
.tooltip_description(HEX_DESCRIPTION)
.on_update(|widget: &TextInput| ColorPickerMessage::SetHexCode { code: widget.value.clone() }.into())
.widget_instance(),
]));
// RGB
groups.push(LayoutGroup::row(vec![
TextLabel::new("RGB").tooltip_label("Red/Green/Blue").tooltip_description("Integers 0255.").widget_instance(),
Separator::new(SeparatorStyle::Related).widget_instance(),
rgb_input(RgbChannel::Red, rgb_255.map(|(r, _, _)| r), "Red Channel", self.disabled),
Separator::new(SeparatorStyle::Related).widget_instance(),
rgb_input(RgbChannel::Green, rgb_255.map(|(_, g, _)| g), "Green Channel", self.disabled),
Separator::new(SeparatorStyle::Related).widget_instance(),
rgb_input(RgbChannel::Blue, rgb_255.map(|(_, _, b)| b), "Blue Channel", self.disabled),
]));
// HSV
groups.push(LayoutGroup::row(vec![
TextLabel::new("HSV")
.tooltip_label("Hue/Saturation/Value")
.tooltip_description("Also known as Hue/Saturation/Brightness (HSB). Not to be confused with Hue/Saturation/Lightness (HSL), a different color model.")
.widget_instance(),
Separator::new(SeparatorStyle::Related).widget_instance(),
hsv_input(
HsvChannel::Hue,
if self.is_none { None } else { Some(self.hue * 360.) },
360.,
"°",
"Hue Component",
HUE_DESCRIPTION,
self.disabled,
),
Separator::new(SeparatorStyle::Related).widget_instance(),
hsv_input(
HsvChannel::Saturation,
if self.is_none { None } else { Some(self.saturation * 100.) },
100.,
"%",
"Saturation Component",
SATURATION_DESCRIPTION,
self.disabled,
),
Separator::new(SeparatorStyle::Related).widget_instance(),
hsv_input(
HsvChannel::Value,
if self.is_none { None } else { Some(self.value * 100.) },
100.,
"%",
"Value Component",
VALUE_DESCRIPTION,
self.disabled,
),
]));
// Alpha
groups.push(LayoutGroup::row(vec![
TextLabel::new("Alpha").tooltip_label("Alpha").tooltip_description(ALPHA_DESCRIPTION).widget_instance(),
Separator::new(SeparatorStyle::Related).widget_instance(),
NumberInput::new(if self.is_none { None } else { Some(self.alpha * 100.) })
.disabled(self.disabled)
.min(0.)
.max(100.)
.mode_range()
.unit("%")
.display_decimal_places(1)
.tooltip_label("Alpha")
.tooltip_description(ALPHA_DESCRIPTION)
.on_update(|widget: &NumberInput| ColorPickerMessage::SetAlphaPercent { value: widget.value }.into())
.on_commit(|_| ColorPickerMessage::StartTransaction.into())
.widget_instance(),
]));
// Color presets (None / Black / White / pure colors / eyedropper)
groups.push(LayoutGroup::row(vec![
ColorPresetsInput::default()
.disabled(self.disabled)
.show_none_option(self.allow_none && self.gradient.is_none())
.on_update(|update: &ColorPresetsInputUpdate| match update {
ColorPresetsInputUpdate::Preset(fill_choice) => ColorPickerMessage::PickPreset { preset: fill_choice.clone() }.into(),
ColorPresetsInputUpdate::EyedropperColorCode(code) => ColorPickerMessage::EyedropperColorCode { code: code.clone() }.into(),
})
.widget_instance(),
]));
Layout(groups)
}
}
fn rgb_input(channel: RgbChannel, value: Option<f64>, tooltip_label: &'static str, disabled: bool) -> WidgetInstance {
NumberInput::new(value)
.disabled(disabled)
.min(0.)
.max(255.)
.min_width(1)
.display_decimal_places(0)
.tooltip_label(tooltip_label)
.tooltip_description("Integers 0255.")
.on_update(move |widget: &NumberInput| ColorPickerMessage::SetChannelRgb { channel, value: widget.value }.into())
.on_commit(|_| ColorPickerMessage::StartTransaction.into())
.widget_instance()
}
fn hsv_input(channel: HsvChannel, value: Option<f64>, max: f64, unit: &'static str, tooltip_label: &'static str, tooltip_description: &'static str, disabled: bool) -> WidgetInstance {
NumberInput::new(value)
.disabled(disabled)
.min(0.)
.max(max)
.min_width(1)
.unit(unit)
.display_decimal_places(1)
.tooltip_label(tooltip_label)
.tooltip_description(tooltip_description)
.on_update(move |widget: &NumberInput| ColorPickerMessage::SetChannelHsv { channel, value: widget.value }.into())
.on_commit(|_| ColorPickerMessage::StartTransaction.into())
.widget_instance()
}
const HEX_DESCRIPTION: &str = "Color code in hexadecimal format. 6 digits if opaque, 8 with alpha. Accepts input of CSS color values including named colors.";
const HUE_DESCRIPTION: &str = "The shade along the spectrum of the rainbow.";
const SATURATION_DESCRIPTION: &str = "The vividness from grayscale to full color.";
const VALUE_DESCRIPTION: &str = "The brightness from black to full color.";
const ALPHA_DESCRIPTION: &str = "The level of translucency, from transparent (0%) to opaque (100%).";
/// Convert an `rgb(0..1)` triple to `hsv(0..1)`. Mirrors the legacy frontend `colorToHSV`.
fn rgb_to_hsv(red: f64, green: f64, blue: f64) -> [f64; 3] {
let max = red.max(green).max(blue);
let min = red.min(green).min(blue);
let delta = max - min;
let mut hue = if delta == 0. {
0.
} else if max == red {
((green - blue) / delta).rem_euclid(6.)
} else if max == green {
(blue - red) / delta + 2.
} else {
(red - green) / delta + 4.
};
hue = (hue * 60. + 360.).rem_euclid(360.) / 360.;
let saturation = if max == 0. { 0. } else { delta / max };
let value = max;
[hue, saturation, value]
}
/// The popover's background color (the `--color-2-mildblack` design token, `#222`). Used by the comparison swatch's
/// outline computation to brighten the inset border for colors close to this background.
const POPOVER_BACKGROUND: Color = Color::from_rgbaf32_unchecked(0x22 as f32 / 255., 0x22 as f32 / 255., 0x22 as f32 / 255., 1.);
/// The luminance window (in linear-light) within which a color is considered close enough to the popover background
/// to warrant an outline. Mirrors the `proximityRange` argument the legacy frontend passed to `contrastingOutlineFactor`.
const OUTLINE_PROXIMITY_RANGE: f64 = 0.01;
/// Returns a 0..1 outline strength for the comparison swatch, growing toward 1 as the color's luminance and saturation
/// both approach the popover background's luminance, when a color would otherwise visually blend into the popover.
fn contrasting_outline_factor(color: Option<Color>) -> f64 {
let Some(color) = color else { return 0. };
// WCAG-style relative luminance, with alpha composited over white in gamma space
let luminance = |color: Color| {
// TODO: Remove the `.to_linear_srgb()` once we move to correctly treating `Color` as linear.
Color::WHITE
.alpha_blend(Color::from_unassociated_alpha(color.r(), color.g(), color.b(), color.a()))
.to_linear_srgb()
.luminance_srgb() as f64
};
let distance = (luminance(POPOVER_BACKGROUND) - luminance(color)).abs().max(0.);
let proximity = 1. - (distance / OUTLINE_PROXIMITY_RANGE).min(1.);
let [_, saturation, _] = rgb_to_hsv(color.r() as f64, color.g() as f64, color.b() as f64);
proximity * (1. - saturation)
}
/// Format a Color as a `#`-prefixed hex string, including the alpha component only if it's not fully opaque.
fn color_to_hex_optional_alpha(color: &Color) -> String {
format!(
"#{}",
if color.a() >= 1. {
color.to_rgb_hex_srgb_from_gamma()
} else {
color.to_rgba_hex_srgb_from_gamma()
}
)
}
/// Parse a CSS color string (named color, hex, `rgb(...)`, etc.) into a `Color` using the `color` crate's CSS Color 4 parser.
/// Tries the input as-is first (catches CSS named colors like `red`, `rgb(...)`, and well-formed hex like `#abcdef`), then falls back to treating the input as bare hex with length-based expansion to a CSS-parseable form:
/// - 1 char `f` → `#fff` (CSS 3-char shorthand)
/// - 2 char `ab` → `#ababab` (repeated to 6 chars)
/// - 4 char `abcd` → `#00abcd` (left-padded with `00`)
/// - 5 char `abcde` → `#0abcde` (left-padded with `0`)
/// - 3, 6, 8 char inputs are passed through with a `#` prefix.
fn parse_css_color(input: &str) -> Option<Color> {
let trimmed = input.trim();
let parsed = color::parse_color(trimmed).ok().or_else(|| {
let bare = trimmed.strip_prefix('#').unwrap_or(trimmed);
if bare.is_empty() || !bare.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let expanded = match bare.len() {
1 => bare.repeat(3),
2 => bare.repeat(3),
4 => format!("00{bare}"),
5 => format!("0{bare}"),
_ => bare.to_string(),
};
let candidate = format!("#{expanded}");
// Avoid retrying the exact same string we just failed to parse.
(candidate != trimmed).then(|| color::parse_color(&candidate).ok()).flatten()
})?;
let srgb: AlphaColor<Srgb> = parsed.to_alpha_color();
let [red, green, blue, alpha] = srgb.components;
Color::from_rgbaf32(red, green, blue, alpha)
}
+7
View File
@@ -0,0 +1,7 @@
mod color_picker_message;
pub mod color_picker_message_handler;
#[doc(inline)]
pub use color_picker_message::{ColorPickerMessage, ColorPickerMessageDiscriminant};
#[doc(inline)]
pub use color_picker_message_handler::ColorPickerMessageHandler;
@@ -16,6 +16,7 @@ use graph_craft::document::NodeId;
use graphene_std::raster::Image;
use graphene_std::raster::color::Color;
use graphene_std::text::{Font, TextAlign};
use graphene_std::vector::style::FillChoice;
use std::path::PathBuf;
#[cfg(not(target_family = "wasm"))]
@@ -167,6 +168,14 @@ pub enum FrontendMessage {
color: Color, // TODO: Color (without `none`) -> Color (with `none`)
position: (f64, f64),
},
/// The Rust color picker handler picked a new color/gradient. The frontend `<ColorPicker>` forwards this as its `colorOrGradient` event.
ColorPickerColorChanged {
value: FillChoice,
},
/// The Rust color picker handler is starting an undo transaction. The frontend `<ColorPicker>` forwards this as its `startHistoryTransaction` event.
ColorPickerStartHistoryTransaction,
/// The Rust color picker handler is committing the in-flight undo transaction. The frontend `<ColorPicker>` forwards this as its `commitHistoryTransaction` event.
ColorPickerCommitHistoryTransaction,
UpdateImportsExports {
/// If the primary import is not visible, then it is None.
imports: Vec<Option<FrontendGraphOutput>>,
@@ -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)]
+2
View File
@@ -14,6 +14,8 @@ pub enum Message {
#[child]
Clipboard(ClipboardMessage),
#[child]
ColorPicker(ColorPickerMessage),
#[child]
Debug(DebugMessage),
#[child]
Defer(DeferMessage),
+1
View File
@@ -4,6 +4,7 @@ pub mod animation;
pub mod app_window;
pub mod broadcast;
pub mod clipboard;
pub mod color_picker;
pub mod debug;
pub mod defer;
pub mod dialog;
+1
View File
@@ -8,6 +8,7 @@ pub use crate::messages::app_window::{AppWindowMessage, AppWindowMessageDiscrimi
pub use crate::messages::broadcast::event::{EventMessage, EventMessageContext, EventMessageDiscriminant, EventMessageHandler};
pub use crate::messages::broadcast::{BroadcastMessage, BroadcastMessageDiscriminant, BroadcastMessageHandler};
pub use crate::messages::clipboard::{ClipboardMessage, ClipboardMessageDiscriminant, ClipboardMessageHandler};
pub use crate::messages::color_picker::{ColorPickerMessage, ColorPickerMessageDiscriminant, ColorPickerMessageHandler};
pub use crate::messages::debug::{DebugMessage, DebugMessageDiscriminant, DebugMessageHandler};
pub use crate::messages::defer::{DeferMessage, DeferMessageDiscriminant, DeferMessageHandler};
pub use crate::messages::dialog::export_dialog::{ExportDialogMessage, ExportDialogMessageContext, ExportDialogMessageDiscriminant, ExportDialogMessageHandler};