diff --git a/Cargo.lock b/Cargo.lock index f46328cf..ba0bbcd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2162,6 +2162,7 @@ version = "0.0.0" dependencies = [ "base64", "bitflags 2.11.0", + "color", "derivative", "dyn-any", "env_logger", diff --git a/Cargo.toml b/Cargo.toml index 983e7ba1..1740899a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -161,6 +161,7 @@ usvg = "0.47" parley = "0.6" skrifa = "0.40" polycool = "0.4" +color = "0.3" # Linebender ecosystem (END) rand = { version = "0.9", default-features = false, features = ["std_rng"] } rand_chacha = "0.9" diff --git a/editor/Cargo.toml b/editor/Cargo.toml index ac7bf6d7..fb83dcd8 100644 --- a/editor/Cargo.toml +++ b/editor/Cargo.toml @@ -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 } diff --git a/editor/src/dispatcher.rs b/editor/src/dispatcher.rs index 58ea6a1a..9b425028 100644 --- a/editor/src/dispatcher.rs +++ b/editor/src/dispatcher.rs @@ -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, ()); } diff --git a/editor/src/messages/color_picker/color_picker_message.rs b/editor/src/messages/color_picker/color_picker_message.rs new file mode 100644 index 00000000..149e2660 --- /dev/null +++ b/editor/src/messages/color_picker/color_picker_message.rs @@ -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 `` 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 }, + /// Numeric HSV channel update. + SetChannelHsv { channel: HsvChannel, value: Option }, + /// Alpha percentage update from the alpha slider numeric input. + SetAlphaPercent { value: Option }, + /// 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, +} diff --git a/editor/src/messages/color_picker/color_picker_message_handler.rs b/editor/src/messages/color_picker/color_picker_message_handler.rs new file mode 100644 index 00000000..9af4410b --- /dev/null +++ b/editor/src/messages/color_picker/color_picker_message_handler.rs @@ -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, + active_marker_index: Option, + 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 for ColorPickerMessageHandler { + fn process_message(&mut self, message: ColorPickerMessage, responses: &mut VecDeque, _context: ()) { + match message { + ColorPickerMessage::Open { initial_value, allow_none, disabled } => { + self.allow_none = allow_none; + self.disabled = disabled; + + // Each `` 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 { + 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 { + 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) { + 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) { + 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) { + // 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 0–255.").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, 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 0–255.") + .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, 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) -> 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 { + 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 = parsed.to_alpha_color(); + let [red, green, blue, alpha] = srgb.components; + Color::from_rgbaf32(red, green, blue, alpha) +} diff --git a/editor/src/messages/color_picker/mod.rs b/editor/src/messages/color_picker/mod.rs new file mode 100644 index 00000000..9fe7c417 --- /dev/null +++ b/editor/src/messages/color_picker/mod.rs @@ -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; diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 6bfd0dac..da7373f2 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -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 `` forwards this as its `colorOrGradient` event. + ColorPickerColorChanged { + value: FillChoice, + }, + /// The Rust color picker handler is starting an undo transaction. The frontend `` forwards this as its `startHistoryTransaction` event. + ColorPickerStartHistoryTransaction, + /// The Rust color picker handler is committing the in-flight undo transaction. The frontend `` forwards this as its `commitHistoryTransaction` event. + ColorPickerCommitHistoryTransaction, UpdateImportsExports { /// If the primary import is not visible, then it is None. imports: Vec>, diff --git a/editor/src/messages/layout/layout_message_handler.rs b/editor/src/messages/layout/layout_message_handler.rs index 7f59e002..d319da5b 100644 --- a/editor/src/messages/layout/layout_message_handler.rs +++ b/editor/src/messages/layout/layout_message_handler.rs @@ -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::(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::(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::(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, action_input_mapping: &impl Fn(&MessageDiscriminant) -> Option, ) { - // 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| 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); + } + _ => {} + } + } +} diff --git a/editor/src/messages/layout/utility_types/layout_widget.rs b/editor/src/messages/layout/utility_types/layout_widget.rs index ee4588a8..f7ec0f20 100644 --- a/editor/src/messages/layout/utility_types/layout_widget.rs +++ b/editor/src/messages/layout/utility_types/layout_widget.rs @@ -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 Default for WidgetCallback { 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` diff --git a/editor/src/messages/layout/utility_types/widgets/button_widgets.rs b/editor/src/messages/layout/utility_types/widgets/button_widgets.rs index cd5f9840..18f30de4 100644 --- a/editor/src/messages/layout/utility_types/widgets/button_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/button_widgets.rs @@ -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, #[serde(rename = "allowNone")] #[derivative(Default(value = "true"))] pub allow_none: bool, diff --git a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs index c7838638..8bf4a3df 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -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, + #[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, + #[widget_builder(constructor)] + #[serde(rename = "oldColor")] + pub old_color: Option, + #[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, + #[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, + #[serde(rename = "activeMarkerIndex")] + pub active_marker_index: Option, + #[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, + #[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, + #[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)] diff --git a/editor/src/messages/message.rs b/editor/src/messages/message.rs index 385b2b45..e9cab24e 100644 --- a/editor/src/messages/message.rs +++ b/editor/src/messages/message.rs @@ -14,6 +14,8 @@ pub enum Message { #[child] Clipboard(ClipboardMessage), #[child] + ColorPicker(ColorPickerMessage), + #[child] Debug(DebugMessage), #[child] Defer(DeferMessage), diff --git a/editor/src/messages/mod.rs b/editor/src/messages/mod.rs index 7b3c39e7..a17aedff 100644 --- a/editor/src/messages/mod.rs +++ b/editor/src/messages/mod.rs @@ -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; diff --git a/editor/src/messages/prelude.rs b/editor/src/messages/prelude.rs index 61c45e35..869d6d8d 100644 --- a/editor/src/messages/prelude.rs +++ b/editor/src/messages/prelude.rs @@ -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}; diff --git a/frontend/src/components/Editor.svelte b/frontend/src/components/Editor.svelte index 5d9e5b41..af9d28c0 100644 --- a/frontend/src/components/Editor.svelte +++ b/frontend/src/components/Editor.svelte @@ -9,6 +9,7 @@ import { createPanicManager, destroyPanicManager } from "/src/managers/panic"; import { createPersistenceManager, destroyPersistenceManager } from "/src/managers/persistence"; import { createAppWindowStore, destroyAppWindowStore } from "/src/stores/app-window"; + import { createColorPickerStore, destroyColorPickerStore } from "/src/stores/color-picker"; import { createDialogStore, destroyDialogStore } from "/src/stores/dialog"; import { createDocumentStore, destroyDocumentStore } from "/src/stores/document"; import { createFullscreenStore, destroyFullscreenStore } from "/src/stores/fullscreen"; @@ -32,6 +33,7 @@ nodeGraph: createNodeGraphStore(subscriptions), portfolio: createPortfolioStore(subscriptions, editor), appWindow: createAppWindowStore(subscriptions), + colorPicker: createColorPickerStore(subscriptions), }; Object.entries(stores).forEach(([key, store]) => setContext(key, store)); @@ -61,6 +63,7 @@ destroyNodeGraphStore(); destroyPortfolioStore(); destroyAppWindowStore(); + destroyColorPickerStore(); // Managers destroyClipboardManager(); diff --git a/frontend/src/components/floating-menus/ColorPicker.svelte b/frontend/src/components/floating-menus/ColorPicker.svelte index 73bf170a..eaf8aa35 100644 --- a/frontend/src/components/floating-menus/ColorPicker.svelte +++ b/frontend/src/components/floating-menus/ColorPicker.svelte @@ -1,59 +1,16 @@ - - - {@const hueDescription = "The shade along the spectrum of the rainbow."} - {@const saturationDescription = "The vividness from grayscale to full color."} - {@const valueDescription = "The brightness from black to full color."} + + - - - {#if alignedAxis} -
-
- {/if} - {#if !isNone} -
- {/if} -
- - {#if !isNone} -
- {/if} -
- - {#if !isNone} -
- {/if} -
-
- {#if gradient} - - dispatch("colorOrGradient", gradient ? { Gradient: gradient } : "None")} - on:activeMarkerIndexChange={gradientActiveMarkerIndexChange} - activeMarkerIndex={activeIndex} - activeMarkerIsMidpoint={activeIndexIsMidpoint} - on:dragging={({ detail }) => (gradientSpectrumDragging = detail)} - bind:this={gradientSpectrumInputWidget} - /> - {#if gradientSpectrumInputWidget && activeIndex !== undefined} - { - if (gradientSpectrumInputWidget && activeIndex !== undefined && position !== undefined) { - gradientSpectrumInputWidget.setPosition(activeIndex, position / 100, activeIndexIsMidpoint); - } - }} - displayDecimalPlaces={0} - min={activeIndexIsMidpoint ? MIN_MIDPOINT * 100 : 0} - max={activeIndexIsMidpoint ? MAX_MIDPOINT * 100 : 100} - unit="%" - /> - {/if} - - {/if} +
- - {#if !colorEquals(newColor, oldColor) && !disabled} -
- - {/if} - - {#if !colorEquals(newColor, oldColor)} - New - {/if} - - {#if !colorEquals(newColor, oldColor)} - - Old - - {/if} -
- - - {@const hexDescription = "Color code in hexadecimal format. 6 digits if opaque, 8 with alpha. Accepts input of CSS color values including named colors."} - Hex - - - { - dispatch("startHistoryTransaction"); - setColorCode(detail); - }} - centered={true} - tooltipLabel="Hex Color Code" - tooltipDescription={hexDescription} - bind:this={hexCodeInputWidget} - /> - - - - RGB - - - {#each rgbChannels as [channel, strength], index} - {#if index > 0} - - {/if} - { - strength = detail; - setColorRGB(channel, detail); - }} - on:startHistoryTransaction={() => { - dispatch("startHistoryTransaction"); - }} - min={0} - max={255} - minWidth={1} - displayDecimalPlaces={0} - tooltipLabel={{ r: "Red Channel", g: "Green Channel", b: "Blue Channel" }[channel]} - tooltipDescription="Integers 0–255." - /> - {/each} - - - - HSV - - - {#each hsvChannels as [channel, strength], index} - {#if index > 0} - - {/if} - { - strength = detail; - setColorHSV(channel, detail); - }} - on:startHistoryTransaction={() => { - dispatch("startHistoryTransaction"); - }} - min={0} - max={channel === "h" ? 360 : 100} - unit={channel === "h" ? "°" : "%"} - minWidth={1} - displayDecimalPlaces={1} - tooltipLabel={{ - h: "Hue Component", - s: "Saturation Component", - v: "Value Component", - }[channel]} - tooltipDescription={{ - h: hueDescription, - s: saturationDescription, - v: valueDescription, - }[channel]} - /> - {/each} - - - - {@const alphaDescription = "The level of translucency, from transparent (0%) to opaque (100%)."} - Alpha - - { - if (detail !== undefined) alpha = detail / 100; - setColorAlphaPercent(detail); - }} - on:startHistoryTransaction={() => { - dispatch("startHistoryTransaction"); - }} - min={0} - max={100} - rangeMin={0} - rangeMax={100} - unit="%" - mode="Range" - displayDecimalPlaces={1} - tooltipLabel="Alpha" - tooltipDescription={alphaDescription} - /> - - - - {#if allowNone && !gradient} - - - {/if} - - - - - - {#if eyedropperSupported()} - - - {/if} - +
@@ -735,136 +74,18 @@ diff --git a/frontend/src/components/widgets/WidgetSection.svelte b/frontend/src/components/widgets/WidgetSection.svelte index 52079782..42d69192 100644 --- a/frontend/src/components/widgets/WidgetSection.svelte +++ b/frontend/src/components/widgets/WidgetSection.svelte @@ -141,7 +141,7 @@ display: none; } - .body { + > .body { padding: 0 7px; padding-top: 1px; margin-top: -1px; @@ -150,7 +150,7 @@ border-radius: 0 0 4px 4px; overflow: hidden; - .widget-span.row { + > .widget-span.row { &:first-child { margin-top: calc(4px - 1px); } diff --git a/frontend/src/components/widgets/WidgetSpan.svelte b/frontend/src/components/widgets/WidgetSpan.svelte index 1bc2aa29..877653da 100644 --- a/frontend/src/components/widgets/WidgetSpan.svelte +++ b/frontend/src/components/widgets/WidgetSpan.svelte @@ -8,20 +8,25 @@ import PopoverButton from "/src/components/widgets/buttons/PopoverButton.svelte"; import TextButton from "/src/components/widgets/buttons/TextButton.svelte"; import CheckboxInput from "/src/components/widgets/inputs/CheckboxInput.svelte"; + import ColorComparisonInput from "/src/components/widgets/inputs/ColorComparisonInput.svelte"; import ColorInput from "/src/components/widgets/inputs/ColorInput.svelte"; + import ColorPresetsInput from "/src/components/widgets/inputs/ColorPresetsInput.svelte"; import CurveInput from "/src/components/widgets/inputs/CurveInput.svelte"; import DropdownInput from "/src/components/widgets/inputs/DropdownInput.svelte"; import NumberInput from "/src/components/widgets/inputs/NumberInput.svelte"; import RadioInput from "/src/components/widgets/inputs/RadioInput.svelte"; import ReferencePointInput from "/src/components/widgets/inputs/ReferencePointInput.svelte"; + import SpectrumInput from "/src/components/widgets/inputs/SpectrumInput.svelte"; import TextAreaInput from "/src/components/widgets/inputs/TextAreaInput.svelte"; import TextInput from "/src/components/widgets/inputs/TextInput.svelte"; + import VisualColorPickersInput from "/src/components/widgets/inputs/VisualColorPickersInput.svelte"; import WorkingColorsInput from "/src/components/widgets/inputs/WorkingColorsInput.svelte"; import IconLabel from "/src/components/widgets/labels/IconLabel.svelte"; import ImageLabel from "/src/components/widgets/labels/ImageLabel.svelte"; import Separator from "/src/components/widgets/labels/Separator.svelte"; import ShortcutLabel from "/src/components/widgets/labels/ShortcutLabel.svelte"; import TextLabel from "/src/components/widgets/labels/TextLabel.svelte"; + import type { ColorPickerStore } from "/src/stores/color-picker"; import { parseFillChoice } from "/src/utility-functions/colors"; import type { EditorWrapper, LayoutTarget, Widget, WidgetInstance } from "/wrapper/pkg/graphite_wasm_wrapper"; @@ -33,6 +38,7 @@ type UnwrappedWidget = { [K in WidgetKind]: [kind: K, props: WidgetProps] }[WidgetKind]; const editor = getContext("editor"); + const colorPickerStore = getContext("colorPicker"); export let widgets: WidgetInstance[]; export let direction: "row" | "column"; @@ -116,6 +122,15 @@ $$events: { checked: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, true) }, }), }, + ColorComparisonInput: { + component: ColorComparisonInput, + getProps: (props, index) => ({ + ...props, + $$events: { + swap: () => widgetValueCommitAndUpdate(index, undefined, true), + }, + }), + }, ColorInput: { component: ColorInput, getProps: (props, index) => ({ @@ -127,6 +142,17 @@ }, }), }, + ColorPresetsInput: { + component: ColorPresetsInput, + getProps: (props, index) => ({ + ...props, + $$events: { + // The widget dispatches `"None"` or a bare `Color`, wrap the color in `{ Solid: ... }` so the payload matches Rust's `FillChoice` shape (which the `Preset` variant expects). + preset: (e: CustomEvent) => widgetValueCommitAndUpdate(index, { Preset: e.detail === "None" ? "None" : { Solid: e.detail } }, true), + eyedropperColorCode: (e: CustomEvent) => widgetValueCommitAndUpdate(index, { EyedropperColorCode: e.detail }, true), + }, + }), + }, CurveInput: { // TODO: CurvesInput is currently unused component: CurveInput, @@ -210,6 +236,28 @@ $$events: { value: (e: CustomEvent) => widgetValueCommitAndUpdate(index, e.detail, true) }, }), }, + SpectrumInput: { + component: SpectrumInput, + getProps: (props, index) => ({ + ...props, + $$events: { + update: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false), + dragging: (e: CustomEvent) => colorPickerStore.setDragging(e.detail), + }, + }), + }, + VisualColorPickersInput: { + component: VisualColorPickersInput, + getProps: (props, index) => ({ + ...props, + $$events: { + update: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false), + startHistoryTransaction: () => widgetValueCommit(index, undefined), + commitHistoryTransaction: () => widgetValueCommit(index, undefined), + dragStateChange: (e: CustomEvent) => colorPickerStore.setDragging(e.detail), + }, + }), + }, PopoverButton: { component: PopoverButton, getProps: (props) => ({ diff --git a/frontend/src/components/widgets/inputs/ColorComparisonInput.svelte b/frontend/src/components/widgets/inputs/ColorComparisonInput.svelte new file mode 100644 index 00000000..627c4321 --- /dev/null +++ b/frontend/src/components/widgets/inputs/ColorComparisonInput.svelte @@ -0,0 +1,178 @@ + + + + {#if differs && !disabled} +
+ dispatch("swap")} tooltipLabel="Swap" /> + {/if} + + {#if differs} + New + {/if} + + {#if differs} + + Old + + {/if} +
+ + diff --git a/frontend/src/components/widgets/inputs/ColorInput.svelte b/frontend/src/components/widgets/inputs/ColorInput.svelte index d0102926..b2e5169f 100644 --- a/frontend/src/components/widgets/inputs/ColorInput.svelte +++ b/frontend/src/components/widgets/inputs/ColorInput.svelte @@ -2,13 +2,14 @@ import { createEventDispatcher } from "svelte"; import ColorPicker from "/src/components/floating-menus/ColorPicker.svelte"; import LayoutCol from "/src/components/layout/LayoutCol.svelte"; - import { contrastingOutlineFactor, fillChoiceColor, fillChoiceGradientStops, colorToHexOptionalAlpha, gradientToLinearGradientCSS } from "/src/utility-functions/colors"; + import { contrastingOutlineFactor, fillChoiceColor, fillChoiceGradientStops } from "/src/utility-functions/colors"; import type { FillChoice, MenuDirection, ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper"; const dispatch = createEventDispatcher<{ value: FillChoice; startHistoryTransaction: undefined }>(); // Content export let value: FillChoice; + export let chosenGradient: string | undefined = undefined; export let allowNone = false; // export let allowTransparency = false; // TODO: Implement export let menuDirection: MenuDirection = "Bottom"; @@ -26,11 +27,6 @@ $: outlined = outlineFactor > 0.0001; $: gradientStops = fillChoiceGradientStops(value); $: solidColor = fillChoiceColor(value); - $: chosenGradient = gradientStops - ? gradientToLinearGradientCSS(gradientStops) - : solidColor - ? `linear-gradient(${colorToHexOptionalAlpha(solidColor)}, ${colorToHexOptionalAlpha(solidColor)})` - : undefined; $: none = value === "None"; $: transparency = gradientStops ? gradientStops.color.some((color) => color.alpha < 1) : solidColor ? solidColor.alpha < 1 : false; diff --git a/frontend/src/components/widgets/inputs/ColorPresetsInput.svelte b/frontend/src/components/widgets/inputs/ColorPresetsInput.svelte new file mode 100644 index 00000000..bdd8b1d9 --- /dev/null +++ b/frontend/src/components/widgets/inputs/ColorPresetsInput.svelte @@ -0,0 +1,169 @@ + + + + {#if showNoneOption} + + + {/if} + + + + + + {#if eyedropperSupported()} + + + {/if} + + + diff --git a/frontend/src/components/widgets/inputs/SpectrumInput.svelte b/frontend/src/components/widgets/inputs/SpectrumInput.svelte index e749e421..3ffb6ad9 100644 --- a/frontend/src/components/widgets/inputs/SpectrumInput.svelte +++ b/frontend/src/components/widgets/inputs/SpectrumInput.svelte @@ -1,323 +1,212 @@ - - (color ? colorToHexOptionalAlpha(color) : "black"))(gradientFirstColor(gradient)), - "--gradient-end": ((color) => (color ? colorToHexOptionalAlpha(color) : "black"))(gradientLastColor(gradient)), - "--gradient-stops": gradientToLinearGradientCSS(gradient), + "--gradient-start": trackStartCSS, + "--gradient-end": trackEndCSS, + "--gradient-stops": trackCSS, }} > - + - {#each toMidpoints(gradient) as midpoint, index} + {#each midpointPositions as midpoint, index} midpointPointerDown(e, index)} - on:dblclick={() => resetMidpoint(index)} + on:dblclick={() => midpointDoubleClick(index)} data-gradient-midpoint xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" @@ -389,13 +263,13 @@ {/each} - - {#each toMarkers(gradient) as marker, index} + + {#each markers as marker, index} markerPointerDown(e, index)} data-gradient-marker xmlns="http://www.w3.org/2000/svg" diff --git a/frontend/src/components/widgets/inputs/VisualColorPickersInput.svelte b/frontend/src/components/widgets/inputs/VisualColorPickersInput.svelte new file mode 100644 index 00000000..ebaaec96 --- /dev/null +++ b/frontend/src/components/widgets/inputs/VisualColorPickersInput.svelte @@ -0,0 +1,377 @@ + + + + {@const hueDescription = "The shade along the spectrum of the rainbow."} + + {#if alignedAxis} +
+
+ {/if} + {#if !isNone} +
+ {/if} +
+ + {#if !isNone} +
+ {/if} +
+ + {#if !isNone} +
+ {/if} +
+
+ + diff --git a/frontend/src/stores/color-picker.ts b/frontend/src/stores/color-picker.ts new file mode 100644 index 00000000..b77e5803 --- /dev/null +++ b/frontend/src/stores/color-picker.ts @@ -0,0 +1,116 @@ +import { writable } from "svelte/store"; +import type { Writable } from "svelte/store"; +import type { SubscriptionsRouter } from "/src/subscriptions-router"; +import { patchLayout } from "/src/utility-functions/widgets"; +import type { FillChoice, Layout } from "/wrapper/pkg/graphite_wasm_wrapper"; + +export type ColorPickerCallbacks = { + onColorChanged?: (value: FillChoice) => void; + onStartTransaction?: () => void; + onCommitTransaction?: () => void; +}; + +export type ColorPickerStoreState = { + pickersAndGradient: Layout; + details: Layout; + callbacks: ColorPickerCallbacks; + // True while the user is actively dragging one of the visual H/S/V/A pickers, so the popover knows to suppress its stray-pointer-close behavior until the drag ends. + isDragging: boolean; +}; + +const initialState: ColorPickerStoreState = { + pickersAndGradient: [], + details: [], + callbacks: {}, + isDragging: false, +}; + +let subscriptionsRouter: SubscriptionsRouter | undefined = undefined; + +// Persist the store across HMR so subscriptions stay live. +const store: Writable = import.meta.hot?.data?.store || writable(initialState); +if (import.meta.hot) import.meta.hot.data.store = store; +const { subscribe, update } = store; + +export type ColorPickerStore = { + subscribe: typeof subscribe; + setCallbacks: (callbacks: ColorPickerCallbacks) => void; + clearCallbacks: () => void; + setDragging: (dragging: boolean) => void; +}; + +// The Rust handler keeps a single shared layout per target, but multiple `` Svelte instances may be mounted across +// the app (one per `ColorInput`/`WorkingColorsInput`/etc.). Subscribing to the layout target from each instance is destructive, +// only the last-registered callback wins. So we maintain a single global subscription here and let each `` instance +// read from the resulting store and register its own per-open callbacks for color/transaction events. +export function createColorPickerStore(subscriptions: SubscriptionsRouter): ColorPickerStore { + destroyColorPickerStore(); + + subscriptionsRouter = subscriptions; + + subscriptions.subscribeFrontendMessage("ColorPickerColorChanged", (data) => { + update((state) => { + state.callbacks.onColorChanged?.(data.value); + return state; + }); + }); + subscriptions.subscribeFrontendMessage("ColorPickerStartHistoryTransaction", () => { + update((state) => { + state.callbacks.onStartTransaction?.(); + return state; + }); + }); + subscriptions.subscribeFrontendMessage("ColorPickerCommitHistoryTransaction", () => { + update((state) => { + state.callbacks.onCommitTransaction?.(); + return state; + }); + }); + + subscriptions.subscribeLayoutUpdate("ColorPickerPickersAndGradient", (diffs) => { + update((state) => { + patchLayout(state.pickersAndGradient, diffs); + return state; + }); + }); + subscriptions.subscribeLayoutUpdate("ColorPickerDetails", (diffs) => { + update((state) => { + patchLayout(state.details, diffs); + return state; + }); + }); + + return { + subscribe, + setCallbacks: (callbacks: ColorPickerCallbacks) => { + update((state) => { + state.callbacks = callbacks; + return state; + }); + }, + clearCallbacks: () => { + update((state) => { + state.callbacks = {}; + state.isDragging = false; + return state; + }); + }, + setDragging: (dragging: boolean) => { + update((state) => { + state.isDragging = dragging; + return state; + }); + }, + }; +} + +export function destroyColorPickerStore() { + const subscriptions = subscriptionsRouter; + if (!subscriptions) return; + + subscriptions.unsubscribeFrontendMessage("ColorPickerColorChanged"); + subscriptions.unsubscribeFrontendMessage("ColorPickerStartHistoryTransaction"); + subscriptions.unsubscribeFrontendMessage("ColorPickerCommitHistoryTransaction"); + subscriptions.unsubscribeLayoutUpdate("ColorPickerPickersAndGradient"); + subscriptions.unsubscribeLayoutUpdate("ColorPickerDetails"); +} diff --git a/frontend/src/utility-functions/colors.ts b/frontend/src/utility-functions/colors.ts index 1d9c1a93..97b55f1d 100644 --- a/frontend/src/utility-functions/colors.ts +++ b/frontend/src/utility-functions/colors.ts @@ -1,4 +1,3 @@ -import { sampleInterpolatedGradient } from "/wrapper/pkg/graphite_wasm_wrapper"; import type { Color, FillChoice, GradientStops } from "/wrapper/pkg/graphite_wasm_wrapper"; // Channels can have any range (0-1, 0-255, 0-100, 0-360) in the context they are being used in, these are just containers for the numbers @@ -63,12 +62,6 @@ export function colorFromCSS(colorCode: string): Color | undefined { return createColor(r / 255, g / 255, b / 255, a / 255); } -export function colorEquals(c1: Color | undefined, c2: Color | undefined): boolean { - if (c1 === undefined && c2 === undefined) return true; - if (c1 === undefined || c2 === undefined) return false; - return Math.abs(c1.red - c2.red) < 1e-6 && Math.abs(c1.green - c2.green) < 1e-6 && Math.abs(c1.blue - c2.blue) < 1e-6 && Math.abs(c1.alpha - c2.alpha) < 1e-6; -} - export function colorToHexNoAlpha(color: Color): string { const r = Math.round(color.red * 255) .toString(16) @@ -83,15 +76,6 @@ export function colorToHexNoAlpha(color: Color): string { return `#${r}${g}${b}`; } -export function colorToHexOptionalAlpha(color: Color): string { - const hex = colorToHexNoAlpha(color); - const a = Math.round(color.alpha * 255) - .toString(16) - .padStart(2, "0"); - - return a === "ff" ? hex : `${hex}${a}`; -} - export function colorToRgb255(color: Color): RGB { return { r: Math.round(color.red * 255), @@ -205,23 +189,6 @@ export function isGradientStops(value: unknown): value is GradientStops { return typeof value === "object" && value !== null && "position" in value && "midpoint" in value && "color" in value; } -export function gradientToLinearGradientCSS(gradient: GradientStops): string { - if (gradient.position.length === 1) { - return `linear-gradient(to right, ${colorToHexOptionalAlpha(gradient.color[0])} 0%, ${colorToHexOptionalAlpha(gradient.color[0])} 100%)`; - } - - const pieces = sampleInterpolatedGradient(new Float64Array(gradient.position), new Float64Array(gradient.midpoint), gradient.color, false); - return `linear-gradient(to right, ${pieces})`; -} - -export function gradientFirstColor(gradient: GradientStops): Color | undefined { - return gradient.color[0]; -} - -export function gradientLastColor(gradient: GradientStops): Color | undefined { - return gradient.color[gradient.color.length - 1]; -} - // FILL CHOICE UTILITY FUNCTIONS export function fillChoiceColor(value: FillChoice): Color | undefined { diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index 6335113d..6f4b4eec 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -24,7 +24,6 @@ use editor::messages::tool::tool_messages::tool_prelude::WidgetId; use graph_craft::document::NodeId; use graphene_std::graphene_hash::CacheHashWrapper; use graphene_std::raster::color::Color; -use graphene_std::vector::GradientStops; use serde::Serialize; use serde_wasm_bindgen::{self, from_value}; use std::cell::RefCell; @@ -703,6 +702,20 @@ impl EditorWrapper { Ok(()) } + /// Initialize the Rust color picker handler with a starting value (used when the frontend `` opens). + #[wasm_bindgen(js_name = openColorPicker)] + pub fn open_color_picker(&self, initial_value: JsValue, allow_none: bool, disabled: bool) -> Result<(), JsValue> { + let initial_value = serde_wasm_bindgen::from_value(initial_value).map_err(|e| Error::new(&format!("Invalid initial picker value: {e}")))?; + self.dispatch(ColorPickerMessage::Open { initial_value, allow_none, disabled }); + Ok(()) + } + + /// Tell the Rust color picker handler that the popover is closing. + #[wasm_bindgen(js_name = closeColorPicker)] + pub fn close_color_picker(&self) { + self.dispatch(ColorPickerMessage::Close); + } + /// Update the color of the currently-edited gradient stop #[wasm_bindgen(js_name = updateGradientStopColor)] pub fn update_gradient_stop_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> { @@ -999,26 +1012,3 @@ pub fn evaluate_math_expression(expression: &str) -> Option { }; Some(real) } - -#[wasm_bindgen(js_name = sampleInterpolatedGradient)] -pub fn sample_interpolated_gradient(position: Vec, midpoint: Vec, color: Vec, omit_alpha: bool) -> String { - let color = color.into_iter().filter_map(|c| serde_wasm_bindgen::from_value(c).ok()).collect(); - GradientStops { position, midpoint, color } - .interpolated_samples() - .into_iter() - .map(|(position, color, _)| { - let hex = if omit_alpha { color.to_rgb_hex_srgb_from_gamma() } else { color.to_rgba_hex_srgb_from_gamma() }; - let percent = ((position * 100.) * 1e2).round() / 1e2; - format!("#{hex} {percent}%") - }) - .collect::>() - .join(", ") -} - -#[wasm_bindgen(js_name = evaluateGradientAtPosition)] -pub fn evaluate_gradient_at_position(t: f64, position: Vec, midpoint: Vec, color: Vec) -> JsValue { - let color = color.into_iter().filter_map(|c| serde_wasm_bindgen::from_value(c).ok()).collect(); - let color = GradientStops { position, midpoint, color }.evaluate(t); - - serde_wasm_bindgen::to_value(&color).unwrap() -} diff --git a/node-graph/graph-craft/src/document.rs b/node-graph/graph-craft/src/document.rs index 8d8c9c5e..933931da 100644 --- a/node-graph/graph-craft/src/document.rs +++ b/node-graph/graph-craft/src/document.rs @@ -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. diff --git a/node-graph/libraries/no-std-types/src/color/color_types.rs b/node-graph/libraries/no-std-types/src/color/color_types.rs index 86a4d50f..2f2a411c 100644 --- a/node-graph/libraries/no-std-types/src/color/color_types.rs +++ b/node-graph/libraries/no-std-types/src/color/color_types.rs @@ -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>, _>` 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] { diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index a307ee2d..a286ccf9 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -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 = (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::>() + .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 diff --git a/node-graph/libraries/vector-types/src/vector/style.rs b/node-graph/libraries/vector-types/src/vector/style.rs index 0cd3e1a9..685cea81 100644 --- a/node-graph/libraries/vector-types/src/vector/style.rs +++ b/node-graph/libraries/vector-types/src/vector/style.rs @@ -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 { + 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 {