Deprecate all usages of the Color struct representing gamma space values, fixing round-trip precision bugs (#4149)

* Deprecate all usages of the Color struct representing gamma space values, fixing round-trip precision bugs

* Code review fixes
This commit is contained in:
Keavon Chambers
2026-05-14 22:48:33 -07:00
committed by GitHub
parent 456a7c868d
commit a56746c6bf
67 changed files with 1210 additions and 941 deletions
Generated
+2
View File
@@ -887,6 +887,7 @@ dependencies = [
"base64", "base64",
"bitflags 2.11.0", "bitflags 2.11.0",
"bytemuck", "bytemuck",
"color",
"ctor", "ctor",
"dyn-any", "dyn-any",
"glam", "glam",
@@ -2216,6 +2217,7 @@ dependencies = [
name = "graphite-wasm-wrapper" name = "graphite-wasm-wrapper"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"bytemuck",
"graph-craft", "graph-craft",
"graphene-std", "graphene-std",
"graphite-editor", "graphite-editor",
@@ -21,7 +21,7 @@ pub enum HsvChannel {
#[impl_message(Message, ColorPicker)] #[impl_message(Message, ColorPicker)]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum ColorPickerMessage { pub enum ColorPickerMessage {
/// Initialize the picker state from an external color/gradient and announce its options. Called by the frontend when a `<ColorPicker>` opens. /// 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 }, Open { initial_value: FillChoice, allow_none: bool, disabled: bool },
/// Clear the picker state. Called by the frontend when the popover closes. /// Clear the picker state. Called by the frontend when the popover closes.
Close, Close,
@@ -2,9 +2,10 @@ use crate::messages::color_picker::color_picker_message::{HsvChannel, RgbChannel
use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::layout::utility_types::widgets::input_widgets::{ColorPresetsInputUpdate, SpectrumInputUpdate, SpectrumMarker, VisualColorPickersInputUpdate}; use crate::messages::layout::utility_types::widgets::input_widgets::{ColorPresetsInputUpdate, SpectrumInputUpdate, SpectrumMarker, VisualColorPickersInputUpdate};
use crate::messages::prelude::*; use crate::messages::prelude::*;
use color::{AlphaColor, Srgb};
use graphene_std::Color; use graphene_std::Color;
use graphene_std::vector::style::{FillChoice, GradientStops}; use graphene_std::color::SRGBA8;
use graphene_std::core_types::misc::parse_css_color;
use graphene_std::vector::style::{FillChoice, FillChoiceUI, GradientStops, GradientStopsUI};
/// Bounds for a midpoint position (relative to the interval between two adjacent gradient stops). /// Bounds for a midpoint position (relative to the interval between two adjacent gradient stops).
const MIN_MIDPOINT: f64 = 0.01; const MIN_MIDPOINT: f64 = 0.01;
@@ -105,10 +106,13 @@ impl MessageHandler<ColorPickerMessage, ()> for ColorPickerMessageHandler {
ColorPickerMessage::SetChannelRgb { channel, value } => { ColorPickerMessage::SetChannelRgb { channel, value } => {
let Some(strength) = value else { return }; let Some(strength) = value else { return };
let Some(current) = self.current_color() else { return }; let Some(current) = self.current_color() else { return };
// The RGB inputs are 0..255 sRGB display values; substitute the new channel into the gamma triple and lift back to linear for storage.
let new_gamma_channel = (strength / 255.) as f32;
let [cur_r, cur_g, cur_b, cur_a] = current.to_gamma_srgb_channels();
let updated = match channel { let updated = match channel {
RgbChannel::Red => Color::from_rgbaf32_unchecked((strength / 255.) as f32, current.g(), current.b(), current.a()), RgbChannel::Red => Color::from_gamma_srgb_channels(new_gamma_channel, cur_g, cur_b, cur_a),
RgbChannel::Green => Color::from_rgbaf32_unchecked(current.r(), (strength / 255.) as f32, current.b(), current.a()), RgbChannel::Green => Color::from_gamma_srgb_channels(cur_r, new_gamma_channel, cur_b, cur_a),
RgbChannel::Blue => Color::from_rgbaf32_unchecked(current.r(), current.g(), (strength / 255.) as f32, current.a()), RgbChannel::Blue => Color::from_gamma_srgb_channels(cur_r, cur_g, new_gamma_channel, cur_a),
}; };
self.adopt_color(updated); self.adopt_color(updated);
self.emit_color(responses); self.emit_color(responses);
@@ -150,7 +154,7 @@ impl MessageHandler<ColorPickerMessage, ()> for ColorPickerMessageHandler {
match preset { match preset {
FillChoice::None => { FillChoice::None => {
self.set_new_hsva(0., 0., 0., 1., true); self.set_new_hsva(0., 0., 0., 1., true);
responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoice::None }); responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoiceUI::None });
} }
FillChoice::Solid(color) => { FillChoice::Solid(color) => {
self.adopt_color(color); self.adopt_color(color);
@@ -175,7 +179,7 @@ impl MessageHandler<ColorPickerMessage, ()> for ColorPickerMessageHandler {
self.set_old_hsva(temp.0, temp.1, temp.2, temp.3, temp.4); self.set_old_hsva(temp.0, temp.1, temp.2, temp.3, temp.4);
if self.is_none { if self.is_none {
responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoice::None }); responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoiceUI::None });
} else { } else {
self.emit_color(responses); self.emit_color(responses);
} }
@@ -197,6 +201,7 @@ impl MessageHandler<ColorPickerMessage, ()> for ColorPickerMessageHandler {
} }
impl ColorPickerMessageHandler { impl ColorPickerMessageHandler {
// The picker's internal HSV state is HSV of sRGB display values
fn current_color(&self) -> Option<Color> { fn current_color(&self) -> Option<Color> {
if self.is_none { if self.is_none {
None None
@@ -239,7 +244,8 @@ impl ColorPickerMessageHandler {
/// Set HSV state from a Color, preserving hue and saturation in degenerate cases. /// Set HSV state from a Color, preserving hue and saturation in degenerate cases.
fn adopt_color(&mut self, color: Color) { 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); let [target_h, target_s, target_v, target_a] = color.to_hsva();
let (target_h, target_s, target_v, target_a) = (target_h as f64, target_s as f64, target_v as f64, target_a 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. // 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. { if !(target_h == 0. && self.hue == 1.) && target_s > 0. && target_v > 0. {
@@ -250,7 +256,7 @@ impl ColorPickerMessageHandler {
self.saturation = target_s; self.saturation = target_s;
} }
self.value = target_v; self.value = target_v;
self.alpha = color.a() as f64; self.alpha = target_a;
self.is_none = false; self.is_none = false;
} }
@@ -264,9 +270,15 @@ impl ColorPickerMessageHandler {
{ {
*stop_color = color; *stop_color = color;
let stops = gradient.clone(); let stops = gradient.clone();
responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoice::Gradient(stops) }); let fill_choice = FillChoice::Gradient(stops);
responses.add(FrontendMessage::ColorPickerColorChanged {
value: FillChoiceUI::from(&fill_choice),
});
} else { } else {
responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoice::Solid(color) }); let fill_choice = FillChoice::Solid(color);
responses.add(FrontendMessage::ColorPickerColorChanged {
value: FillChoiceUI::from(&fill_choice),
});
} }
} }
@@ -364,8 +376,9 @@ impl ColorPickerMessageHandler {
} }
self.gradient = Some(gradient.clone()); self.gradient = Some(gradient.clone());
let fill_choice = FillChoice::Gradient(gradient);
responses.add(FrontendMessage::ColorPickerColorChanged { responses.add(FrontendMessage::ColorPickerColorChanged {
value: FillChoice::Gradient(gradient), value: FillChoiceUI::from(&fill_choice),
}); });
self.send_layouts(responses); self.send_layouts(responses);
} }
@@ -392,7 +405,7 @@ impl ColorPickerMessageHandler {
// For gradient editing, the markers' handle colors mirror their gradient stop colors // 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 markers = gradient.iter().map(|stop| SpectrumMarker::new(stop.position, stop.midpoint, stop.color)).collect();
let mut row_widgets = vec![ let mut row_widgets = vec![
SpectrumInput::new(gradient.clone()) SpectrumInput::new(GradientStopsUI::from(gradient))
.markers(markers) .markers(markers)
.active_marker_index(self.active_marker_index) .active_marker_index(self.active_marker_index)
.active_marker_is_midpoint(self.active_marker_is_midpoint) .active_marker_is_midpoint(self.active_marker_is_midpoint)
@@ -453,7 +466,11 @@ impl ColorPickerMessageHandler {
let old_color = self.old_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 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.)); // RGB readouts display sRGB byte values to the user, so we convert from linear-light to gamma here before quantizing.
let rgb_255 = new_color.map(|c| {
let [r, g, b, _] = c.to_gamma_srgb_channels();
(r as f64 * 255., g as f64 * 255., b as f64 * 255.)
});
// Epsilon comparison since the picker round-trips through HSV // Epsilon comparison since the picker round-trips through HSV
let differs = match (new_color, old_color) { let differs = match (new_color, old_color) {
@@ -470,7 +487,7 @@ impl ColorPickerMessageHandler {
// New/old comparison swatch with swap button // New/old comparison swatch with swap button
groups.push(LayoutGroup::row(vec![ groups.push(LayoutGroup::row(vec![
ColorComparisonInput::new(new_color, old_color) ColorComparisonInput::new(new_color.map(SRGBA8::from), old_color.map(SRGBA8::from))
.is_none(self.is_none) .is_none(self.is_none)
.old_is_none(self.old_is_none) .old_is_none(self.old_is_none)
.disabled(self.disabled) .disabled(self.disabled)
@@ -566,7 +583,10 @@ impl ColorPickerMessageHandler {
.disabled(self.disabled) .disabled(self.disabled)
.show_none_option(self.allow_none && self.gradient.is_none()) .show_none_option(self.allow_none && self.gradient.is_none())
.on_update(|update: &ColorPresetsInputUpdate| match update { .on_update(|update: &ColorPresetsInputUpdate| match update {
ColorPresetsInputUpdate::Preset(fill_choice) => ColorPickerMessage::PickPreset { preset: fill_choice.clone() }.into(), ColorPresetsInputUpdate::Preset(fill_choice) => ColorPickerMessage::PickPreset {
preset: FillChoice::from(fill_choice),
}
.into(),
ColorPresetsInputUpdate::EyedropperColorCode(code) => ColorPickerMessage::EyedropperColorCode { code: code.clone() }.into(), ColorPresetsInputUpdate::EyedropperColorCode(code) => ColorPickerMessage::EyedropperColorCode { code: code.clone() }.into(),
}) })
.widget_instance(), .widget_instance(),
@@ -611,33 +631,10 @@ const SATURATION_DESCRIPTION: &str = "The vividness from grayscale to full color
const VALUE_DESCRIPTION: &str = "The brightness from black 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%)."; 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`. /// The popover's background color as sRGB gamma-encoded channels (the `--color-2-mildblack` design token, `#222`).
fn rgb_to_hsv(red: f64, green: f64, blue: f64) -> [f64; 3] { /// Used by the comparison swatch's outline computation to brighten the inset border for colors close to this background.
let max = red.max(green).max(blue); const POPOVER_BACKGROUND_GAMMA_CHANNELS: [f32; 4] = [0x22 as f32 / 255., 0x22 as f32 / 255., 0x22 as f32 / 255., 1.];
let min = red.min(green).min(blue); /// The luminance window within which a color is considered close enough to the popover background
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`. /// to warrant an outline. Mirrors the `proximityRange` argument the legacy frontend passed to `contrastingOutlineFactor`.
const OUTLINE_PROXIMITY_RANGE: f64 = 0.01; const OUTLINE_PROXIMITY_RANGE: f64 = 0.01;
@@ -646,61 +643,22 @@ const OUTLINE_PROXIMITY_RANGE: f64 = 0.01;
fn contrasting_outline_factor(color: Option<Color>) -> f64 { fn contrasting_outline_factor(color: Option<Color>) -> f64 {
let Some(color) = color else { return 0. }; let Some(color) = color else { return 0. };
// WCAG-style relative luminance, with alpha composited over white in gamma space // WCAG-style relative luminance, with alpha composited over white in sRGB gamma space (matching the perceptual intent of `SRGBA8::contrasting_text_color`).
let luminance = |color: Color| { let luminance_from_gamma_channels = |[r, g, b, a]: [f32; 4]| -> f64 {
// TODO: Remove the `.to_linear_srgb()` once we move to correctly treating `Color` as linear. let inv_a = 1. - a;
Color::WHITE Color::from_gamma_srgb_channels(inv_a + r * a, inv_a + g * a, inv_a + b * a, 1.).luminance_rec_709() as f64
.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 color_gamma_channels = color.to_gamma_srgb_channels();
let distance = (luminance_from_gamma_channels(POPOVER_BACKGROUND_GAMMA_CHANNELS) - luminance_from_gamma_channels(color_gamma_channels))
.abs()
.max(0.);
let proximity = 1. - (distance / OUTLINE_PROXIMITY_RANGE).min(1.); 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); let [_, saturation, _, _] = color.to_hsva();
proximity * (1. - saturation) proximity * (1. - saturation as f64)
} }
/// Format a Color as a `#`-prefixed hex string, including the alpha component only if it's not fully opaque. /// Format a linear `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 { fn color_to_hex_optional_alpha(color: &Color) -> String {
format!( SRGBA8::from(*color).to_css_hex()
"#{}",
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)
} }
@@ -13,10 +13,10 @@ use crate::messages::portfolio::utility_types::WorkspacePanelLayout;
use crate::messages::prelude::*; use crate::messages::prelude::*;
use crate::messages::tool::tool_messages::eyedropper_tool::PrimarySecondary; use crate::messages::tool::tool_messages::eyedropper_tool::PrimarySecondary;
use graph_craft::document::NodeId; use graph_craft::document::NodeId;
use graphene_std::color::SRGBA8;
use graphene_std::raster::Image; use graphene_std::raster::Image;
use graphene_std::raster::color::Color;
use graphene_std::text::Font; use graphene_std::text::Font;
use graphene_std::vector::style::FillChoice; use graphene_std::vector::style::FillChoiceUI;
use std::path::PathBuf; use std::path::PathBuf;
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
@@ -167,16 +167,16 @@ pub enum FrontendMessage {
document_id: DocumentId, document_id: DocumentId,
}, },
UpdateGradientStopColorPickerPosition { UpdateGradientStopColorPickerPosition {
color: Color, // TODO: Color (without `none`) -> Color (with `none`) color: SRGBA8, // TODO: Color (without `none`) -> Color (with `none`)
position: (f64, f64), position: (f64, f64),
}, },
/// The Rust color picker handler picked a new color/gradient. The frontend `<ColorPicker>` forwards this as its `colorOrGradient` event. /// The Rust color picker handler picked a new color/gradient. The frontend `<ColorPicker />` forwards this as its `colorOrGradient` event.
ColorPickerColorChanged { ColorPickerColorChanged {
value: FillChoice, value: FillChoiceUI,
}, },
/// The Rust color picker handler is starting an undo transaction. The frontend `<ColorPicker>` forwards this as its `startHistoryTransaction` event. /// The Rust color picker handler is starting an undo transaction. The frontend `<ColorPicker />` forwards this as its `startHistoryTransaction` event.
ColorPickerStartHistoryTransaction, ColorPickerStartHistoryTransaction,
/// The Rust color picker handler is committing the in-flight undo transaction. The frontend `<ColorPicker>` forwards this as its `commitHistoryTransaction` event. /// The Rust color picker handler is committing the in-flight undo transaction. The frontend `<ColorPicker />` forwards this as its `commitHistoryTransaction` event.
ColorPickerCommitHistoryTransaction, ColorPickerCommitHistoryTransaction,
UpdateImportsExports { UpdateImportsExports {
/// If the primary import is not visible, then it is None. /// If the primary import is not visible, then it is None.
@@ -241,7 +241,7 @@ pub enum FrontendMessage {
svg: String, svg: String,
}, },
UpdateImageData { UpdateImageData {
image_data: Vec<(u64, Image<Color>)>, image_data: Vec<(u64, Image<SRGBA8>)>,
}, },
UpdateDocumentLayerDetails { UpdateDocumentLayerDetails {
data: LayerPanelEntry, data: LayerPanelEntry,
@@ -14,7 +14,7 @@ pub struct DocumentInfo {
pub is_saved: bool, pub is_saved: bool,
} }
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))] #[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints, from_wasm_abi))]
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] #[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PersistedState { pub struct PersistedState {
pub documents: Vec<DocumentInfo>, pub documents: Vec<DocumentInfo>,
@@ -1,7 +1,8 @@
use crate::messages::input_mapper::utility_types::input_keyboard::KeysGroup; use crate::messages::input_mapper::utility_types::input_keyboard::KeysGroup;
use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::prelude::*; use crate::messages::prelude::*;
use graphene_std::vector::style::FillChoice; use graphene_std::color::SRGBA8;
use graphene_std::vector::style::FillChoiceUI;
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
@@ -179,11 +180,11 @@ impl LayoutMessageHandler {
let callback_message = match action { let callback_message = match action {
WidgetValueAction::Commit => (color_button.on_commit.callback)(&()), WidgetValueAction::Commit => (color_button.on_commit.callback)(&()),
WidgetValueAction::Update => { WidgetValueAction::Update => {
let Ok(fill_choice) = serde_json::from_value::<FillChoice>(value) else { let Ok(fill_choice_ui) = serde_json::from_value::<FillChoiceUI>(value) else {
warn!("ColorInput update was not able to be parsed as FillChoice: {color_button:?}"); warn!("ColorInput update was not able to be parsed as FillChoiceUI: {color_button:?}");
return; return;
}; };
color_button.value = fill_choice; color_button.value = fill_choice_ui;
(color_button.on_update.callback)(color_button) (color_button.on_update.callback)(color_button)
} }
}; };
@@ -496,25 +497,14 @@ fn populate_computed_display_fields(layout: &mut Layout) {
} }
Widget::SpectrumInput(spectrum_input) => { Widget::SpectrumInput(spectrum_input) => {
spectrum_input.track_css = spectrum_input.track.to_css_linear_gradient(); spectrum_input.track_css = spectrum_input.track.to_css_linear_gradient();
spectrum_input.track_start_css = spectrum_input spectrum_input.track_start_css = spectrum_input.track.color.first().map(|color| color.to_css_hex()).unwrap_or_else(|| "black".to_string());
.track spectrum_input.track_end_css = spectrum_input.track.color.last().map(|color| color.to_css_hex()).unwrap_or_else(|| "black".to_string());
.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) => { Widget::ColorComparisonInput(comparison) => {
use graphene_std::Color; let contrasting = |color: Option<SRGBA8>| color.map_or(SRGBA8::BLACK, |color| color.contrasting_text_color()).to_css_hex();
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| color.to_css_hex()).unwrap_or_default();
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.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_css = comparison.old_color.map(|color| color.to_css_hex()).unwrap_or_default();
comparison.old_color_contrasting = contrasting(comparison.old_color); comparison.old_color_contrasting = contrasting(comparison.old_color);
} }
_ => {} _ => {}
@@ -22,7 +22,7 @@ impl core::fmt::Display for WidgetId {
macro_rules! define_layout_target { macro_rules! define_layout_target {
($($(#[$attr:meta])* $variant:ident),* $(,)?) => { ($($(#[$attr:meta])* $variant:ident),* $(,)?) => {
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
#[derive(PartialEq, Clone, Debug, Hash, Eq, Copy, serde::Serialize, serde::Deserialize)] #[derive(PartialEq, Clone, Debug, Hash, Eq, Copy, serde::Serialize, serde::Deserialize)]
#[repr(u8)] #[repr(u8)]
pub enum LayoutTarget { pub enum LayoutTarget {
@@ -4,7 +4,7 @@ use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::portfolio::document::node_graph::utility_types::FrontendGraphDataType; use crate::messages::portfolio::document::node_graph::utility_types::FrontendGraphDataType;
use crate::messages::tool::tool_messages::tool_prelude::WidgetCallback; use crate::messages::tool::tool_messages::tool_prelude::WidgetCallback;
use derivative::*; use derivative::*;
use graphene_std::vector::style::FillChoice; use graphene_std::vector::style::FillChoiceUI;
use graphite_proc_macros::WidgetBuilder; use graphite_proc_macros::WidgetBuilder;
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
@@ -187,11 +187,10 @@ pub struct ImageButton {
#[derivative(Debug, PartialEq, Default)] #[derivative(Debug, PartialEq, Default)]
pub struct ColorInput { pub struct ColorInput {
// Content // Content
/// WARNING: The colors are gamma, not linear!
#[widget_builder(constructor)] #[widget_builder(constructor)]
pub value: FillChoice, pub value: FillChoiceUI,
/// CSS `linear-gradient(...)` (or solid-color stand-in) for the swatch's `background-image`. Auto-populated from `value` at layout-send time. /// 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. /// `None` when `value` is `FillChoiceUI::None`, in which case the frontend uses its "none" fallback styling.
#[serde(rename = "chosenGradient")] #[serde(rename = "chosenGradient")]
#[widget_builder(skip)] #[widget_builder(skip)]
pub chosen_gradient: Option<String>, pub chosen_gradient: Option<String>,
@@ -4,8 +4,9 @@ use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier;
use derivative::*; use derivative::*;
use graphene_std::Color; use graphene_std::Color;
use graphene_std::color::SRGBA8;
use graphene_std::transform::ReferencePoint; use graphene_std::transform::ReferencePoint;
use graphene_std::vector::style::{FillChoice, GradientStops}; use graphene_std::vector::style::{FillChoiceUI, GradientStopsUI};
use graphite_proc_macros::WidgetBuilder; use graphite_proc_macros::WidgetBuilder;
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
@@ -349,9 +350,9 @@ pub struct RadioEntryData {
pub struct WorkingColorsInput { pub struct WorkingColorsInput {
// Content // Content
#[widget_builder(constructor)] #[widget_builder(constructor)]
pub primary: Color, pub primary: SRGBA8,
#[widget_builder(constructor)] #[widget_builder(constructor)]
pub secondary: Color, pub secondary: SRGBA8,
} }
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
@@ -484,10 +485,10 @@ pub struct ColorComparisonInput {
// Content // Content
#[widget_builder(constructor)] #[widget_builder(constructor)]
#[serde(rename = "newColor")] #[serde(rename = "newColor")]
pub new_color: Option<Color>, pub new_color: Option<SRGBA8>,
#[widget_builder(constructor)] #[widget_builder(constructor)]
#[serde(rename = "oldColor")] #[serde(rename = "oldColor")]
pub old_color: Option<Color>, pub old_color: Option<SRGBA8>,
#[serde(rename = "isNone")] #[serde(rename = "isNone")]
pub is_none: bool, pub is_none: bool,
#[serde(rename = "oldIsNone")] #[serde(rename = "oldIsNone")]
@@ -544,7 +545,7 @@ pub struct ColorPresetsInput {
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum ColorPresetsInputUpdate { pub enum ColorPresetsInputUpdate {
Preset(FillChoice), Preset(FillChoiceUI),
EyedropperColorCode(String), EyedropperColorCode(String),
} }
@@ -555,7 +556,7 @@ pub struct SpectrumInput {
// Content // Content
/// The colored gradient drawn behind the markers (display-only, caller-owned). /// The colored gradient drawn behind the markers (display-only, caller-owned).
#[widget_builder(constructor)] #[widget_builder(constructor)]
pub track: GradientStops, pub track: GradientStopsUI,
/// CSS `linear-gradient(...)` string for the track strip's `background-image`. Auto-populated from `track` at layout-send time. /// CSS `linear-gradient(...)` string for the track strip's `background-image`. Auto-populated from `track` at layout-send time.
#[serde(rename = "trackCSS")] #[serde(rename = "trackCSS")]
#[widget_builder(skip)] #[widget_builder(skip)]
@@ -607,14 +608,14 @@ pub struct SpectrumMarker {
position: f64, 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. /// 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, midpoint: f64,
/// CSS color string for the marker handle's fill. Set via `SpectrumMarker::new` from a [`Color`] (gamma space). /// CSS color string for the marker handle's fill. Set via `SpectrumMarker::new` from a linear [`Color`].
#[serde(rename = "handleColorCSS")] #[serde(rename = "handleColorCSS")]
handle_color_css: String, handle_color_css: String,
} }
impl SpectrumMarker { impl SpectrumMarker {
pub fn new(position: f64, midpoint: f64, handle_color: Color) -> Self { pub fn new(position: f64, midpoint: f64, handle_color: Color) -> Self {
let handle_color_css = format!("#{}", handle_color.to_rgba_hex_srgb_from_gamma()); let handle_color_css = SRGBA8::from(handle_color).to_css_hex();
Self { position, midpoint, handle_color_css } Self { position, midpoint, handle_color_css }
} }
} }
@@ -7,12 +7,13 @@ use crate::messages::tool::tool_messages::tool_prelude::*;
use glam::{Affine2, DAffine2, Vec2}; use glam::{Affine2, DAffine2, Vec2};
use graph_craft::document::NodeId; use graph_craft::document::NodeId;
use graphene_std::blending::BlendMode; use graphene_std::blending::BlendMode;
use graphene_std::color::SRGBA8;
use graphene_std::gradient::GradientStops; use graphene_std::gradient::GradientStops;
use graphene_std::list::List; use graphene_std::list::List;
use graphene_std::memo::IORecord; use graphene_std::memo::IORecord;
use graphene_std::raster_types::{CPU, GPU, Raster}; use graphene_std::raster_types::{CPU, GPU, Raster};
use graphene_std::vector::Vector; use graphene_std::vector::Vector;
use graphene_std::vector::style::{Fill, FillChoice, GradientSpreadMethod, GradientType}; use graphene_std::vector::style::{Fill, FillChoice, FillChoiceUI, GradientSpreadMethod, GradientType};
use graphene_std::{Artboard, Color, Context, Graphic}; use graphene_std::{Artboard, Color, Context, Graphic};
use std::any::Any; use std::any::Any;
use std::sync::Arc; use std::sync::Arc;
@@ -385,11 +386,15 @@ impl TableItemLayout for Vector {
match self.style.fill.clone() { match self.style.fill.clone() {
Fill::None => table_rows.push(vec![ Fill::None => table_rows.push(vec![
TextLabel::new("Fill").narrow(true).widget_instance(), TextLabel::new("Fill").narrow(true).widget_instance(),
ColorInput::new(FillChoice::None).disabled(true).menu_direction(Some(MenuDirection::Top)).narrow(true).widget_instance(), ColorInput::new(FillChoiceUI::None)
.disabled(true)
.menu_direction(Some(MenuDirection::Top))
.narrow(true)
.widget_instance(),
]), ]),
Fill::Solid(color) => table_rows.push(vec![ Fill::Solid(color) => table_rows.push(vec![
TextLabel::new("Fill").narrow(true).widget_instance(), TextLabel::new("Fill").narrow(true).widget_instance(),
ColorInput::new(FillChoice::Solid(color)) ColorInput::new(FillChoiceUI::from(&FillChoice::Solid(color)))
.disabled(true) .disabled(true)
.menu_direction(Some(MenuDirection::Top)) .menu_direction(Some(MenuDirection::Top))
.narrow(true) .narrow(true)
@@ -398,7 +403,7 @@ impl TableItemLayout for Vector {
Fill::Gradient(gradient) => { Fill::Gradient(gradient) => {
table_rows.push(vec![ table_rows.push(vec![
TextLabel::new("Fill").narrow(true).widget_instance(), TextLabel::new("Fill").narrow(true).widget_instance(),
ColorInput::new(FillChoice::Gradient(gradient.stops)) ColorInput::new(FillChoiceUI::from(&FillChoice::Gradient(gradient.stops)))
.disabled(true) .disabled(true)
.menu_direction(Some(MenuDirection::Top)) .menu_direction(Some(MenuDirection::Top))
.narrow(true) .narrow(true)
@@ -423,7 +428,11 @@ impl TableItemLayout for Vector {
let color = if let Some(color) = stroke.color { FillChoice::Solid(color) } else { FillChoice::None }; let color = if let Some(color) = stroke.color { FillChoice::Solid(color) } else { FillChoice::None };
table_rows.push(vec![ table_rows.push(vec![
TextLabel::new("Stroke").narrow(true).widget_instance(), TextLabel::new("Stroke").narrow(true).widget_instance(),
ColorInput::new(color).disabled(true).menu_direction(Some(MenuDirection::Top)).narrow(true).widget_instance(), ColorInput::new(FillChoiceUI::from(&color))
.disabled(true)
.menu_direction(Some(MenuDirection::Top))
.narrow(true)
.widget_instance(),
]); ]);
table_rows.push(vec![ table_rows.push(vec![
TextLabel::new("Stroke Weight").narrow(true).widget_instance(), TextLabel::new("Stroke Weight").narrow(true).widget_instance(),
@@ -561,10 +570,10 @@ impl TableItemLayout for Color {
"Color" "Color"
} }
fn identifier(&self) -> String { fn identifier(&self) -> String {
format!("Color (#{})", self.to_gamma_srgb().to_rgba_hex_srgb()) format!("Color (#{})", SRGBA8::from(*self).to_rgba_hex())
} }
fn value_widget(&self, _target: PathStep, _data: &LayoutData) -> WidgetInstance { fn value_widget(&self, _target: PathStep, _data: &LayoutData) -> WidgetInstance {
ColorInput::new(FillChoice::Solid(*self)) ColorInput::new(FillChoiceUI::from(&FillChoice::Solid(*self)))
.disabled(true) .disabled(true)
.menu_direction(Some(MenuDirection::Top)) .menu_direction(Some(MenuDirection::Top))
.narrow(true) .narrow(true)
@@ -584,7 +593,7 @@ impl TableItemLayout for GradientStops {
format!("Gradient ({} stops)", self.len()) format!("Gradient ({} stops)", self.len())
} }
fn value_widget(&self, _target: PathStep, _data: &LayoutData) -> WidgetInstance { fn value_widget(&self, _target: PathStep, _data: &LayoutData) -> WidgetInstance {
ColorInput::new(FillChoice::Gradient(self.clone())) ColorInput::new(FillChoiceUI::from(&FillChoice::Gradient(self.clone())))
.menu_direction(Some(MenuDirection::Top)) .menu_direction(Some(MenuDirection::Top))
.disabled(true) .disabled(true)
.narrow(true) .narrow(true)
@@ -30,7 +30,7 @@ use graphene_std::text_nodes::StringCapitalization;
use graphene_std::transform::{Footprint, ReferencePoint, ScaleType, Transform}; use graphene_std::transform::{Footprint, ReferencePoint, ScaleType, Transform};
use graphene_std::vector::misc::BooleanOperation; use graphene_std::vector::misc::BooleanOperation;
use graphene_std::vector::misc::{ArcType, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType}; use graphene_std::vector::misc::{ArcType, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType};
use graphene_std::vector::style::{Fill, FillChoice, GradientSpreadMethod, GradientStops, GradientType, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; use graphene_std::vector::style::{Fill, FillChoice, FillChoiceUI, GradientSpreadMethod, GradientStops, GradientStopsUI, GradientType, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin};
use graphene_std::vector::{QRCodeErrorCorrectionLevel, VectorModification}; use graphene_std::vector::{QRCodeErrorCorrectionLevel, VectorModification};
pub(crate) fn string_properties(text: &str) -> Vec<LayoutGroup> { pub(crate) fn string_properties(text: &str) -> Vec<LayoutGroup> {
@@ -1164,19 +1164,19 @@ pub fn color_widget(parameter_widgets_info: ParameterWidgetsInfo, color_button:
match &**tagged_value { match &**tagged_value {
TaggedValue::Color(color) => widgets.push( TaggedValue::Color(color) => widgets.push(
color_button color_button
.value(match color { .value(FillChoiceUI::from(&match color {
Some(color) => FillChoice::Solid(*color), Some(color) => FillChoice::Solid(*color),
None => FillChoice::None, None => FillChoice::None,
}) }))
.on_update(update_value(|input: &ColorInput| TaggedValue::Color(input.value.as_solid()), node_id, index)) .on_update(update_value(|input: &ColorInput| TaggedValue::Color(input.value.as_solid().map(Color::from)), node_id, index))
.on_commit(commit_value) .on_commit(commit_value)
.widget_instance(), .widget_instance(),
), ),
TaggedValue::Gradient(stops) => widgets.push( TaggedValue::Gradient(stops) => widgets.push(
color_button color_button
.value(FillChoice::Gradient(stops.clone())) .value(FillChoiceUI::from(&FillChoice::Gradient(stops.clone())))
.on_update(update_value( .on_update(update_value(
|input: &ColorInput| TaggedValue::Gradient(input.value.as_gradient().cloned().unwrap_or_default()), |input: &ColorInput| TaggedValue::Gradient(input.value.as_gradient().map(GradientStops::from).unwrap_or_default()),
node_id, node_id,
index, index,
)) ))
@@ -1417,7 +1417,7 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo
// Build the shared spectrum widget (placed on the first non-exposed row) // Build the shared spectrum widget (placed on the first non-exposed row)
let spectrum_widget = (!spectrum_markers.is_empty()).then(|| { let spectrum_widget = (!spectrum_markers.is_empty()).then(|| {
SpectrumInput::new(bw_track()) SpectrumInput::new(GradientStopsUI::from(&bw_track()))
.markers(spectrum_markers) .markers(spectrum_markers)
.show_midpoints(false) .show_midpoints(false)
.allow_insert(false) .allow_insert(false)
@@ -1591,7 +1591,7 @@ fn spectrum_slider_row(
let position_to_value = move |position: f64| value_min + position * value_range; let position_to_value = move |position: f64| value_min + position * value_range;
row.push( row.push(
SpectrumInput::new(track) SpectrumInput::new(GradientStopsUI::from(&track))
.markers(vec![SpectrumMarker::new(position, 0.5, handle_color)]) .markers(vec![SpectrumMarker::new(position, 0.5, handle_color)])
.show_midpoints(false) .show_midpoints(false)
.allow_insert(false) .allow_insert(false)
@@ -2482,7 +2482,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte
widgets_first_row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); widgets_first_row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance());
widgets_first_row.push( widgets_first_row.push(
ColorInput::default() ColorInput::default()
.value(fill.clone().into()) .value(FillChoiceUI::from(&FillChoice::from(fill.clone())))
.on_update(move |x: &ColorInput| Message::Batched { .on_update(move |x: &ColorInput| Message::Batched {
messages: Box::new([ messages: Box::new([
match &fill2 { match &fill2 {
@@ -2508,7 +2508,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte
NodeGraphMessage::SetInputValue { NodeGraphMessage::SetInputValue {
node_id, node_id,
input_index: FillInput::<Color>::INDEX, input_index: FillInput::<Color>::INDEX,
value: TaggedValue::Fill(x.value.to_fill(fill2.as_gradient())), value: TaggedValue::Fill(FillChoice::from(&x.value).to_fill(fill2.as_gradient())),
} }
.into(), .into(),
]), ]),
@@ -3,9 +3,9 @@ use crate::messages::portfolio::document::overlays::utility_types::OverlayContex
use crate::messages::portfolio::document::utility_types::misc::{GridSnapping, GridType}; use crate::messages::portfolio::document::utility_types::misc::{GridSnapping, GridType};
use crate::messages::prelude::*; use crate::messages::prelude::*;
use glam::DVec2; use glam::DVec2;
use graphene_std::raster::color::Color; use graphene_std::color::SRGBA8;
use graphene_std::renderer::Quad; use graphene_std::renderer::Quad;
use graphene_std::vector::style::FillChoice; use graphene_std::vector::style::FillChoiceUI;
fn grid_overlay_rectangular(document: &DocumentMessageHandler, overlay_context: &mut OverlayContext, spacing: DVec2) { fn grid_overlay_rectangular(document: &DocumentMessageHandler, overlay_context: &mut OverlayContext, spacing: DVec2) {
let origin = document.snapping_state.grid.origin; let origin = document.snapping_state.grid.origin;
@@ -274,12 +274,12 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec<LayoutGroup> {
Separator::new(SeparatorStyle::Related).widget_instance(), Separator::new(SeparatorStyle::Related).widget_instance(),
]); ]);
color_widgets.push( color_widgets.push(
ColorInput::new(FillChoice::Solid(Color::from_hex_str(&grid.color).unwrap_or(Color::BLACK))) ColorInput::new(FillChoiceUI::Solid(SRGBA8::from_hex_str(&grid.color).unwrap_or(SRGBA8::BLACK)))
.tooltip_label("Grid Display Color") .tooltip_label("Grid Display Color")
.allow_none(false) .allow_none(false)
.on_update(update_val::<ColorInput, _>(grid, |grid, color| { .on_update(update_val::<ColorInput, _>(grid, |grid, color| {
if let Some(color) = color.value.as_solid() { if let Some(srgba) = color.value.as_solid() {
grid.color = format!("#{}", color.to_rgba_hex_srgb_from_gamma()); grid.color = srgba.to_css_hex();
} }
})) }))
.widget_instance(), .widget_instance(),
@@ -999,13 +999,10 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
}); });
// Add default fill and stroke to the layer // Add default fill and stroke to the layer
let fill_color = Color::WHITE; let fill = graphene_std::vector::style::Fill::solid(Color::WHITE);
let stroke_color = Color::BLACK;
let fill = graphene_std::vector::style::Fill::solid(fill_color.to_gamma_srgb());
responses.add(GraphOperationMessage::FillSet { layer, fill }); responses.add(GraphOperationMessage::FillSet { layer, fill });
let stroke = graphene_std::vector::style::Stroke::new(Some(stroke_color.to_gamma_srgb()), DEFAULT_STROKE_WIDTH); let stroke = graphene_std::vector::style::Stroke::new(Some(Color::BLACK), DEFAULT_STROKE_WIDTH);
responses.add(GraphOperationMessage::StrokeSet { layer, stroke }); responses.add(GraphOperationMessage::StrokeSet { layer, stroke });
// Create new point ids and add those into the existing Vector path // Create new point ids and add those into the existing Vector path
@@ -34,6 +34,7 @@ impl FontCatalog {
} }
} }
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FontCatalogFamily { pub struct FontCatalogFamily {
/// The font family name. /// The font family name.
@@ -55,6 +56,7 @@ impl FontCatalogFamily {
} }
} }
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FontCatalogStyle { pub struct FontCatalogStyle {
pub weight: u32, pub weight: u32,
@@ -88,7 +90,7 @@ impl FontCatalogStyle {
} }
} }
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
#[derive(PartialEq, Eq, Hash, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] #[derive(PartialEq, Eq, Hash, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
pub enum PanelType { pub enum PanelType {
Welcome, Welcome,
@@ -124,7 +126,7 @@ impl PanelType {
pub struct PanelGroupId(pub u64); pub struct PanelGroupId(pub u64);
/// Which edge of a panel group to split on when docking a dragged panel. /// Which edge of a panel group to split on when docking a dragged panel.
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum DockingSplitDirection { pub enum DockingSplitDirection {
Left, Left,
@@ -6,7 +6,7 @@ use crate::messages::prelude::*;
use crate::messages::tool::common_functionality::graph_modification_utils; use crate::messages::tool::common_functionality::graph_modification_utils;
use crate::messages::tool::utility_types::DocumentToolData; use crate::messages::tool::utility_types::DocumentToolData;
use graphene_std::Color; use graphene_std::Color;
use graphene_std::vector::style::{FillChoice, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; use graphene_std::vector::style::{FillChoice, FillChoiceUI, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin};
/// Color selector widgets seen in [`LayoutTarget::ToolOptions`] bar. /// Color selector widgets seen in [`LayoutTarget::ToolOptions`] bar.
pub struct ToolColorOptions { pub struct ToolColorOptions {
@@ -47,13 +47,12 @@ impl ToolColorOptions {
self.enabled == Some(true) self.enabled == Some(true)
} }
/// The active solid color in linear sRGB, suitable for storing in a working color or downstream rendering input. /// The active solid color, suitable for storing in a working color or downstream rendering input.
/// `fill_choice` is stored in gamma space (per [`FillChoice`]'s contract), so this method converts to linear before returning.
pub fn active_color(&self) -> Option<Color> { pub fn active_color(&self) -> Option<Color> {
if !self.is_active() { if !self.is_active() {
return None; return None;
} }
Some(self.fill_choice.as_ref()?.as_solid()?.to_linear_srgb()) self.fill_choice.as_ref()?.as_solid()
} }
pub fn apply_fill(&self, layer: LayerNodeIdentifier, responses: &mut VecDeque<Message>) { pub fn apply_fill(&self, layer: LayerNodeIdentifier, responses: &mut VecDeque<Message>) {
@@ -86,7 +85,8 @@ impl ToolColorOptions {
// In the mixed state (`fill_choice` is `None`) the dash overlay covers the swatch, so the underlying widget value just drives the picker's initial position. // In the mixed state (`fill_choice` is `None`) the dash overlay covers the swatch, so the underlying widget value just drives the picker's initial position.
// `FillChoice::None` gives it a neutral starting point. // `FillChoice::None` gives it a neutral starting point.
let mixed_color = self.fill_choice.is_none(); let mixed_color = self.fill_choice.is_none();
let widget_value = self.fill_choice.clone().unwrap_or(FillChoice::None); // Convert the internal linear-light `FillChoice` to the JS-boundary `FillChoiceUI` (with `SRGBA8` colors) for the widget value.
let widget_value = FillChoiceUI::from(self.fill_choice.as_ref().unwrap_or(&FillChoice::None));
let mixed_enabled = self.enabled.is_none(); let mixed_enabled = self.enabled.is_none();
// In the mixed-enabled state the underlying `checked` value is hidden behind the indeterminate dash. // In the mixed-enabled state the underlying `checked` value is hidden behind the indeterminate dash.
// The frontend's click handler sends `true` when the user resolves the mixed state by clicking. // The frontend's click handler sends `true` when the user resolves the mixed state by clicking.
@@ -191,10 +191,9 @@ impl DrawingToolState {
} }
} }
/// Builds a `FillChoice::Solid` from a linear-space color, applying gamma conversion to display sRGB. /// Builds a `FillChoice::Solid` from a color.
/// Common helper used throughout the color-syncing code where working colors (linear) flow into swatches that store gamma-encoded colors. pub fn solid(color: Color) -> FillChoice {
pub fn solid_gamma(color: Color) -> FillChoice { FillChoice::Solid(color)
FillChoice::Solid(color.to_gamma_srgb())
} }
/// The fill working color (the source for the fill swatch when nothing is selected). /// The fill working color (the source for the fill swatch when nothing is selected).
@@ -219,8 +218,8 @@ pub fn sync_color_options(
document: &DocumentMessageHandler, document: &DocumentMessageHandler,
selection_changed: bool, selection_changed: bool,
) -> bool { ) -> bool {
let fill_fallback = solid_gamma(fill_working_color(global, drawing.colors_swapped)); let fill_fallback = solid(fill_working_color(global, drawing.colors_swapped));
let stroke_fallback = solid_gamma(stroke_working_color(global, drawing.colors_swapped)); let stroke_fallback = solid(stroke_working_color(global, drawing.colors_swapped));
let mut changed = false; let mut changed = false;
@@ -364,7 +363,7 @@ fn sync_stroke_options(drawing: &mut DrawingToolState, document: &DocumentMessag
/// Same as [`sync_color_options`] but for tools that only have a fill option (e.g., text). The fill follows the given working color when nothing is selected. /// Same as [`sync_color_options`] but for tools that only have a fill option (e.g., text). The fill follows the given working color when nothing is selected.
pub fn sync_fill_only(fill: &mut ToolColorOptions, natural_fill_enabled: bool, fill_color: Color, document: &DocumentMessageHandler, selection_changed: bool) -> bool { pub fn sync_fill_only(fill: &mut ToolColorOptions, natural_fill_enabled: bool, fill_color: Color, document: &DocumentMessageHandler, selection_changed: bool) -> bool {
let fill_fallback = solid_gamma(fill_color); let fill_fallback = solid(fill_color);
let new_fill = if let Some(state) = graph_modification_utils::selected_fill_state(document) { let new_fill = if let Some(state) = graph_modification_utils::selected_fill_state(document) {
let active = state.enabled == Some(true); let active = state.enabled == Some(true);
@@ -417,11 +416,7 @@ pub fn apply_fill_only_color_pick(fill: &mut ToolColorOptions, fill_choice: Fill
} }
graph_modification_utils::set_fill_for_selected_layers(fill_choice, document, responses); graph_modification_utils::set_fill_for_selected_layers(fill_choice, document, responses);
} else if let FillChoice::Solid(color) = fill_choice { } else if let FillChoice::Solid(color) = fill_choice {
// Swatch is gamma; working colors are linear. responses.add(ToolMessage::SelectWorkingColor { color, primary: slot_is_primary });
responses.add(ToolMessage::SelectWorkingColor {
color: color.to_linear_srgb(),
primary: slot_is_primary,
});
} }
} }
@@ -436,9 +431,8 @@ pub fn apply_stroke_color_pick(drawing: &mut DrawingToolState, color: Option<Col
} }
graph_modification_utils::set_stroke_color_for_selected_layers(color, drawing.effective_line_weight(), document, responses); graph_modification_utils::set_stroke_color_for_selected_layers(color, drawing.effective_line_weight(), document, responses);
} else if let Some(color) = color { } else if let Some(color) = color {
// Swatch is gamma; working colors are linear.
responses.add(ToolMessage::SelectWorkingColor { responses.add(ToolMessage::SelectWorkingColor {
color: color.to_linear_srgb(), color,
primary: !drawing.colors_swapped, primary: !drawing.colors_swapped,
}); });
} }
@@ -459,14 +453,14 @@ pub fn apply_fill_only_enabled(fill: &mut ToolColorOptions, enabled: bool, worki
// Mixed re-tick has no per-layer color to restore; fall back to the working color and keep tracking it. // Mixed re-tick has no per-layer color to restore; fall back to the working color and keep tracking it.
let fill_choice = fill.fill_choice.clone().unwrap_or_else(|| { let fill_choice = fill.fill_choice.clone().unwrap_or_else(|| {
fill.tracks_working_color = true; fill.tracks_working_color = true;
solid_gamma(working_color) solid(working_color)
}); });
fill.fill_choice = Some(fill_choice.clone()); fill.fill_choice = Some(fill_choice.clone());
graph_modification_utils::set_fill_for_selected_layers(fill_choice, document, responses); graph_modification_utils::set_fill_for_selected_layers(fill_choice, document, responses);
} else { } else {
// Unticking from mixed: capture the working color as the saved value so the swatch keeps following the link. // Unticking from mixed: capture the working color as the saved value so the swatch keeps following the link.
if fill.fill_choice.is_none() { if fill.fill_choice.is_none() {
fill.fill_choice = Some(solid_gamma(working_color)); fill.fill_choice = Some(solid(working_color));
fill.tracks_working_color = true; fill.tracks_working_color = true;
} }
graph_modification_utils::remove_fill_for_selected_layers(document, responses); graph_modification_utils::remove_fill_for_selected_layers(document, responses);
@@ -482,13 +476,13 @@ pub fn apply_stroke_enabled(drawing: &mut DrawingToolState, enabled: bool, globa
if enabled { if enabled {
let stroke_choice = drawing.stroke.fill_choice.clone().unwrap_or_else(|| { let stroke_choice = drawing.stroke.fill_choice.clone().unwrap_or_else(|| {
drawing.stroke.tracks_working_color = true; drawing.stroke.tracks_working_color = true;
solid_gamma(stroke_working_color(global, drawing.colors_swapped)) solid(stroke_working_color(global, drawing.colors_swapped))
}); });
drawing.stroke.fill_choice = Some(stroke_choice.clone()); drawing.stroke.fill_choice = Some(stroke_choice.clone());
graph_modification_utils::set_stroke_color_for_selected_layers(stroke_choice.as_solid(), drawing.effective_line_weight(), document, responses); graph_modification_utils::set_stroke_color_for_selected_layers(stroke_choice.as_solid(), drawing.effective_line_weight(), document, responses);
} else { } else {
if drawing.stroke.fill_choice.is_none() { if drawing.stroke.fill_choice.is_none() {
drawing.stroke.fill_choice = Some(solid_gamma(stroke_working_color(global, drawing.colors_swapped))); drawing.stroke.fill_choice = Some(solid(stroke_working_color(global, drawing.colors_swapped)));
drawing.stroke.tracks_working_color = true; drawing.stroke.tracks_working_color = true;
} }
graph_modification_utils::remove_stroke_for_selected_layers(document, responses); graph_modification_utils::remove_stroke_for_selected_layers(document, responses);
@@ -513,14 +507,14 @@ pub fn apply_working_colors(drawing: &mut DrawingToolState, global: &DocumentToo
/// Refreshes a single swatch from the given working color, subject to the rules in [`apply_working_colors`]. /// Refreshes a single swatch from the given working color, subject to the rules in [`apply_working_colors`].
pub fn refresh_slot_working_color(slot: &mut ToolColorOptions, working_color: Color, document: &DocumentMessageHandler) { pub fn refresh_slot_working_color(slot: &mut ToolColorOptions, working_color: Color, document: &DocumentMessageHandler) {
if slot.fill_choice.is_some() && (!has_selection(document) || slot.tracks_working_color) { if slot.fill_choice.is_some() && (!has_selection(document) || slot.tracks_working_color) {
slot.fill_choice = Some(solid_gamma(working_color)); slot.fill_choice = Some(solid(working_color));
} }
} }
/// Resets the tool's swatches to the working colors. Called on tool deactivation and shape-mode changes. /// Resets the tool's swatches to the working colors. Called on tool deactivation and shape-mode changes.
pub fn reset_colors_on_deactivation(drawing: &mut DrawingToolState, global: &DocumentToolData) { pub fn reset_colors_on_deactivation(drawing: &mut DrawingToolState, global: &DocumentToolData) {
drawing.fill.fill_choice = Some(solid_gamma(fill_working_color(global, drawing.colors_swapped))); drawing.fill.fill_choice = Some(solid(fill_working_color(global, drawing.colors_swapped)));
drawing.stroke.fill_choice = Some(solid_gamma(stroke_working_color(global, drawing.colors_swapped))); drawing.stroke.fill_choice = Some(solid(stroke_working_color(global, drawing.colors_swapped)));
drawing.fill.tracks_working_color = true; drawing.fill.tracks_working_color = true;
drawing.stroke.tracks_working_color = true; drawing.stroke.tracks_working_color = true;
} }
@@ -329,10 +329,10 @@ pub fn get_fill_color(layer: LayerNodeIdentifier, network_interface: &NodeNetwor
let fill_index = 1; let fill_index = 1;
let inputs = NodeGraphLayer::new(layer, network_interface).find_node_inputs(&DefinitionIdentifier::ProtoNode(graphene_std::vector::fill::IDENTIFIER))?; let inputs = NodeGraphLayer::new(layer, network_interface).find_node_inputs(&DefinitionIdentifier::ProtoNode(graphene_std::vector::fill::IDENTIFIER))?;
let TaggedValue::Fill(Fill::Solid(color)) = inputs.get(fill_index)?.as_value()? else { let &TaggedValue::Fill(Fill::Solid(color)) = inputs.get(fill_index)?.as_value()? else {
return None; return None;
}; };
Some(color.to_linear_srgb()) Some(color)
} }
/// Get the current blend mode of a layer from the closest upstream "Blend Mode" node. /// Get the current blend mode of a layer from the closest upstream "Blend Mode" node.
@@ -9,6 +9,7 @@ use crate::messages::prelude::*;
use crate::messages::tool::transform_layer::transform_layer_message_handler::TransformLayerMessageContext; use crate::messages::tool::transform_layer::transform_layer_message_handler::TransformLayerMessageContext;
use crate::messages::tool::utility_types::{HintData, ToolType}; use crate::messages::tool::utility_types::{HintData, ToolType};
use crate::node_graph_executor::NodeGraphExecutor; use crate::node_graph_executor::NodeGraphExecutor;
use graphene_std::color::SRGBA8;
use graphene_std::raster::color::Color; use graphene_std::raster::color::Color;
const ARTBOARD_OVERLAY_PROVIDER: OverlayProvider = |context| DocumentMessage::DrawArtboardOverlays { context }.into(); const ARTBOARD_OVERLAY_PROVIDER: OverlayProvider = |context| DocumentMessage::DrawArtboardOverlays { context }.into();
@@ -280,7 +281,7 @@ impl MessageHandler<ToolMessage, ToolMessageContext<'_>> for ToolMessageHandler
let r = (random_number >> 16) as u8; let r = (random_number >> 16) as u8;
let g = (random_number >> 8) as u8; let g = (random_number >> 8) as u8;
let b = random_number as u8; let b = random_number as u8;
let random_color = Color::from_rgba8_srgb(r, g, b, 255); let random_color = Color::from(SRGBA8::new(r, g, b, 255));
if primary { if primary {
document_data.primary_color = random_color; document_data.primary_color = random_color;
@@ -4,13 +4,13 @@ use crate::messages::portfolio::document::graph_operation::transform_utils::get_
use crate::messages::portfolio::document::node_graph::document_node_definitions::{DefinitionIdentifier, resolve_proto_node_type}; use crate::messages::portfolio::document::node_graph::document_node_definitions::{DefinitionIdentifier, resolve_proto_node_type};
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::network_interface::FlowType; use crate::messages::portfolio::document::utility_types::network_interface::FlowType;
use crate::messages::tool::common_functionality::color_selector::{ToolColorOptions, solid_gamma}; use crate::messages::tool::common_functionality::color_selector::{ToolColorOptions, solid};
use graph_craft::document::NodeId; use graph_craft::document::NodeId;
use graph_craft::document::value::TaggedValue; use graph_craft::document::value::TaggedValue;
use graphene_std::Color; use graphene_std::Color;
use graphene_std::brush::brush_stroke::{BrushInputSample, BrushStroke, BrushStyle}; use graphene_std::brush::brush_stroke::{BrushInputSample, BrushStroke, BrushStyle};
use graphene_std::raster::BlendMode; use graphene_std::raster::BlendMode;
use graphene_std::vector::style::FillChoice; use graphene_std::vector::style::{FillChoice, FillChoiceUI};
const BRUSH_MAX_SIZE: f64 = 5000.; const BRUSH_MAX_SIZE: f64 = 5000.;
@@ -104,13 +104,12 @@ impl ToolMetadata for BrushTool {
impl LayoutHolder for BrushTool { impl LayoutHolder for BrushTool {
fn layout(&self) -> Layout { fn layout(&self) -> Layout {
let mut widgets = vec![ let mut widgets = vec![
ColorInput::new(self.options.color.fill_choice.clone().unwrap_or(FillChoice::None)) ColorInput::new(FillChoiceUI::from(self.options.color.fill_choice.as_ref().unwrap_or(&FillChoice::None)))
.mixed(self.options.color.fill_choice.is_none()) .mixed(self.options.color.fill_choice.is_none())
.narrow(true) .narrow(true)
.on_update(|color: &ColorInput| { .on_update(|color: &ColorInput| {
BrushToolMessage::UpdateOptions { BrushToolMessage::UpdateOptions {
// The picker emits gamma-space colors; working colors are stored in linear sRGB. options: BrushToolMessageOptionsUpdate::Color(color.value.as_solid().map(Color::from)),
options: BrushToolMessageOptionsUpdate::Color(color.value.as_solid().map(|c| c.to_linear_srgb())),
} }
.into() .into()
}) })
@@ -245,7 +244,7 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for Brus
} }
} }
BrushToolMessageOptionsUpdate::WorkingColorsChanged => { BrushToolMessageOptionsUpdate::WorkingColorsChanged => {
self.options.color.fill_choice = Some(solid_gamma(context.global_tool_data.primary_color)); self.options.color.fill_choice = Some(solid(context.global_tool_data.primary_color));
} }
} }
@@ -1,6 +1,7 @@
use super::tool_prelude::*; use super::tool_prelude::*;
use crate::messages::frontend::utility_types::EyedropperPreviewImage; use crate::messages::frontend::utility_types::EyedropperPreviewImage;
use crate::messages::tool::utility_types::DocumentToolData; use crate::messages::tool::utility_types::DocumentToolData;
use graphene_std::color::SRGBA8;
use graphene_std::vector::style::RenderMode; use graphene_std::vector::style::RenderMode;
#[derive(Default, ExtractField)] #[derive(Default, ExtractField)]
@@ -233,8 +234,8 @@ fn update_cursor_preview_common(
responses.add(FrontendMessage::UpdateEyedropperSamplingState { responses.add(FrontendMessage::UpdateEyedropperSamplingState {
image, image,
mouse_position: Some(input.mouse.position.into()), mouse_position: Some(input.mouse.position.into()),
primary_color: "#".to_string() + global_tool_data.primary_color.to_rgb_hex_srgb().as_str(), primary_color: SRGBA8::from(global_tool_data.primary_color).to_css_hex(),
secondary_color: "#".to_string() + global_tool_data.secondary_color.to_rgb_hex_srgb().as_str(), secondary_color: SRGBA8::from(global_tool_data.secondary_color).to_css_hex(),
set_color_choice, set_color_choice,
}); });
} }
@@ -1,9 +1,10 @@
use super::tool_prelude::*; use super::tool_prelude::*;
use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext;
use crate::messages::tool::common_functionality::color_selector::solid_gamma; use crate::messages::tool::common_functionality::color_selector::solid;
use crate::messages::tool::common_functionality::graph_modification_utils::NodeGraphLayer; use crate::messages::tool::common_functionality::graph_modification_utils::NodeGraphLayer;
use graphene_std::color::SRGBA8;
use graphene_std::raster::color::Color; use graphene_std::raster::color::Color;
use graphene_std::vector::style::Fill; use graphene_std::vector::style::{Fill, FillChoiceUI};
#[derive(Default, ExtractField)] #[derive(Default, ExtractField)]
pub struct FillTool { pub struct FillTool {
@@ -43,11 +44,11 @@ impl ToolMetadata for FillTool {
impl LayoutHolder for FillTool { impl LayoutHolder for FillTool {
fn layout(&self) -> Layout { fn layout(&self) -> Layout {
let widgets = vec![ let widgets = vec![
ColorInput::new(solid_gamma(self.primary_color)) ColorInput::new(FillChoiceUI::from(&solid(self.primary_color)))
.narrow(true) .narrow(true)
.on_update(|color: &ColorInput| { .on_update(|color: &ColorInput| {
FillToolMessage::SetColor { FillToolMessage::SetColor {
color: color.value.as_solid().map(|c| c.to_linear_srgb()), color: color.value.as_solid().map(Color::from),
} }
.into() .into()
}) })
@@ -141,7 +142,7 @@ impl Fsm for FillToolFsmState {
// Get the layer the user is hovering over // Get the layer the user is hovering over
if let Some(layer) = document.click(input, viewport) { if let Some(layer) = document.click(input, viewport) {
let color_hex = format!("#{}", preview_color.to_rgba_hex_srgb()); let color_hex = SRGBA8::from(preview_color).to_css_hex();
overlay_context.fill_path_pattern(document.metadata().layer_outline(layer), document.metadata().transform_to_viewport(layer), &color_hex); overlay_context.fill_path_pattern(document.metadata().layer_outline(layer), document.metadata().transform_to_viewport(layer), &color_hex);
} }
@@ -161,8 +162,8 @@ impl Fsm for FillToolFsmState {
return self; return self;
} }
let fill = match color_event { let fill = match color_event {
FillToolMessage::FillPrimaryColor => Fill::Solid(global_tool_data.primary_color.to_gamma_srgb()), FillToolMessage::FillPrimaryColor => Fill::Solid(global_tool_data.primary_color),
FillToolMessage::FillSecondaryColor => Fill::Solid(global_tool_data.secondary_color.to_gamma_srgb()), FillToolMessage::FillSecondaryColor => Fill::Solid(global_tool_data.secondary_color),
_ => return self, _ => return self,
}; };
@@ -201,6 +202,7 @@ impl Fsm for FillToolFsmState {
#[cfg(test)] #[cfg(test)]
mod test_fill { mod test_fill {
pub use crate::test_utils::test_prelude::*; pub use crate::test_utils::test_prelude::*;
use graphene_std::color::SRGBA8;
use graphene_std::vector::fill; use graphene_std::vector::fill;
use graphene_std::vector::style::Fill; use graphene_std::vector::style::Fill;
@@ -240,7 +242,7 @@ mod test_fill {
editor.click_tool(ToolType::Fill, MouseKeys::LEFT, DVec2::new(2., 2.), ModifierKeys::empty()).await; editor.click_tool(ToolType::Fill, MouseKeys::LEFT, DVec2::new(2., 2.), ModifierKeys::empty()).await;
let fills = get_fills(&mut editor).await; let fills = get_fills(&mut editor).await;
assert_eq!(fills.len(), 1); assert_eq!(fills.len(), 1);
assert_eq!(fills[0].as_solid().unwrap().to_rgba8_srgb(), Color::GREEN.to_rgba8_srgb()); assert_eq!(SRGBA8::from(fills[0].as_solid().unwrap()), SRGBA8::from(Color::GREEN));
} }
#[tokio::test] #[tokio::test]
@@ -252,6 +254,6 @@ mod test_fill {
editor.click_tool(ToolType::Fill, MouseKeys::LEFT, DVec2::new(2., 2.), ModifierKeys::SHIFT).await; editor.click_tool(ToolType::Fill, MouseKeys::LEFT, DVec2::new(2., 2.), ModifierKeys::SHIFT).await;
let fills = get_fills(&mut editor).await; let fills = get_fills(&mut editor).await;
assert_eq!(fills.len(), 1); assert_eq!(fills.len(), 1);
assert_eq!(fills[0].as_solid().unwrap().to_rgba8_srgb(), Color::YELLOW.to_rgba8_srgb()); assert_eq!(SRGBA8::from(fills[0].as_solid().unwrap()), SRGBA8::from(Color::YELLOW));
} }
} }
@@ -97,7 +97,7 @@ impl LayoutHolder for FreehandTool {
}, },
|color: &ColorInput| { |color: &ColorInput| {
FreehandToolMessage::UpdateOptions { FreehandToolMessage::UpdateOptions {
options: FreehandOptionsUpdate::FillColor(color.value.clone()), options: FreehandOptionsUpdate::FillColor(FillChoice::from(&color.value)),
} }
.into() .into()
}, },
@@ -127,7 +127,7 @@ impl LayoutHolder for FreehandTool {
}, },
|color: &ColorInput| { |color: &ColorInput| {
FreehandToolMessage::UpdateOptions { FreehandToolMessage::UpdateOptions {
options: FreehandOptionsUpdate::StrokeColor(color.value.as_solid()), options: FreehandOptionsUpdate::StrokeColor(color.value.as_solid().map(Color::from)),
} }
.into() .into()
}, },
@@ -11,8 +11,9 @@ use crate::messages::tool::common_functionality::auto_panning::AutoPanning;
use crate::messages::tool::common_functionality::graph_modification_utils::{self, NodeGraphLayer, get_gradient_stops}; use crate::messages::tool::common_functionality::graph_modification_utils::{self, NodeGraphLayer, get_gradient_stops};
use crate::messages::tool::common_functionality::snapping::{SnapCandidatePoint, SnapConstraint, SnapData, SnapManager, SnapTypeConfiguration}; use crate::messages::tool::common_functionality::snapping::{SnapCandidatePoint, SnapConstraint, SnapData, SnapManager, SnapTypeConfiguration};
use graph_craft::document::value::TaggedValue; use graph_craft::document::value::TaggedValue;
use graphene_std::color::SRGBA8;
use graphene_std::raster::color::Color; use graphene_std::raster::color::Color;
use graphene_std::vector::style::{Fill, FillChoice, Gradient, GradientSpreadMethod, GradientStop, GradientStops, GradientType}; use graphene_std::vector::style::{Fill, FillChoice, FillChoiceUI, Gradient, GradientSpreadMethod, GradientStop, GradientStops, GradientStopsUI, GradientType};
#[derive(Default, ExtractField)] #[derive(Default, ExtractField)]
pub struct GradientTool { pub struct GradientTool {
@@ -49,7 +50,7 @@ pub enum GradientToolMessage {
CommitTransactionForColorStop, CommitTransactionForColorStop,
CloseStopColorPicker, CloseStopColorPicker,
UpdateStopColor { color: Color }, UpdateStopColor { color: Color },
UpdateStops { stops: GradientStops }, UpdateStops { stops: GradientStopsUI },
UpdateOptions { options: GradientOptionsUpdate }, UpdateOptions { options: GradientOptionsUpdate },
} }
@@ -120,7 +121,7 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for Grad
} }
} }
ToolMessage::Gradient(GradientToolMessage::UpdateStops { stops }) => { ToolMessage::Gradient(GradientToolMessage::UpdateStops { stops }) => {
apply_stops_update(&mut self.data, context, responses, stops); apply_stops_update(&mut self.data, context, responses, GradientStops::from(&stops));
} }
ToolMessage::Gradient(GradientToolMessage::CloseStopColorPicker) => { ToolMessage::Gradient(GradientToolMessage::CloseStopColorPicker) => {
if self.data.color_picker_transaction_open { if self.data.color_picker_transaction_open {
@@ -243,7 +244,7 @@ impl LayoutHolder for GradientTool {
}, },
])) ]))
}); });
let stops_widget = ColorInput::new(stops_value) let stops_widget = ColorInput::new(FillChoiceUI::from(&stops_value))
.allow_none(false) .allow_none(false)
.narrow(true) .narrow(true)
.tooltip_label("Gradient Stops") .tooltip_label("Gradient Stops")
@@ -856,7 +857,7 @@ impl Fsm for GradientToolFsmState {
let (start, end) = (transform.transform_point2(*start), transform.transform_point2(*end)); let (start, end) = (transform.transform_point2(*start), transform.transform_point2(*end));
fn color_to_hex(color: graphene_std::Color) -> String { fn color_to_hex(color: graphene_std::Color) -> String {
format!("#{}", color.to_rgb_hex_srgb_from_gamma()) SRGBA8::from(color).to_css_hex()
} }
let start_hex = stops.color.first().map(|&c| color_to_hex(c)).unwrap_or(String::from(COLOR_OVERLAY_BLUE)); let start_hex = stops.color.first().map(|&c| color_to_hex(c)).unwrap_or(String::from(COLOR_OVERLAY_BLUE));
@@ -1024,10 +1025,10 @@ impl Fsm for GradientToolFsmState {
let transform = gradient_space_transform(layer, document); let transform = gradient_space_transform(layer, document);
let gradient = &selected_gradient.gradient; let gradient = &selected_gradient.gradient;
if stop_index < gradient.stops.position.len() { if stop_index < gradient.stops.position.len() {
let color = gradient.stops.color[stop_index].to_gamma_srgb(); let color = gradient.stops.color[stop_index];
let position = gradient.stops.position[stop_index]; let position = gradient.stops.position[stop_index];
let position = transform.transform_point2(gradient.start.lerp(gradient.end, position)).into(); let position = transform.transform_point2(gradient.start.lerp(gradient.end, position)).into();
responses.add(FrontendMessage::UpdateGradientStopColorPickerPosition { color, position }); responses.add(FrontendMessage::UpdateGradientStopColorPickerPosition { color: color.into(), position });
} }
} }
@@ -1081,9 +1082,9 @@ impl Fsm for GradientToolFsmState {
.transform .transform
.transform_point2(selected_gradient.gradient.start.lerp(selected_gradient.gradient.end, stop_pos)); .transform_point2(selected_gradient.gradient.start.lerp(selected_gradient.gradient.end, stop_pos));
let position = viewport_pos.into(); let position = viewport_pos.into();
let color = selected_gradient.gradient.stops.color[stop_index].to_gamma_srgb(); let color = selected_gradient.gradient.stops.color[stop_index];
tool_data.color_picker_editing_color_stop = Some(stop_index); tool_data.color_picker_editing_color_stop = Some(stop_index);
responses.add(FrontendMessage::UpdateGradientStopColorPickerPosition { color, position }); responses.add(FrontendMessage::UpdateGradientStopColorPickerPosition { color: color.into(), position });
} }
} }
_ => {} _ => {}
@@ -1910,6 +1911,7 @@ mod test_gradient {
pub use crate::test_utils::test_prelude::*; pub use crate::test_utils::test_prelude::*;
use glam::DAffine2; use glam::DAffine2;
use graph_craft::document::value::TaggedValue; use graph_craft::document::value::TaggedValue;
use graphene_std::color::SRGBA8;
use graphene_std::vector::style::{Fill, Gradient}; use graphene_std::vector::style::{Fill, Gradient};
use graphene_std::vector::{GradientStop, GradientStops, fill}; use graphene_std::vector::{GradientStop, GradientStops, fill};
@@ -2022,8 +2024,8 @@ mod test_gradient {
let (gradient, transform) = get_gradient(&mut editor).await; let (gradient, transform) = get_gradient(&mut editor).await;
// Gradient goes from primary color to secondary color // Gradient goes from primary color to secondary color
let stops = gradient.stops.iter().map(|stop| (stop.position, stop.color.to_rgba8_srgb())).collect::<Vec<_>>(); let stops = gradient.stops.iter().map(|stop| (stop.position, SRGBA8::from(stop.color))).collect::<Vec<_>>();
assert_eq!(stops, vec![(0., Color::GREEN.to_rgba8_srgb()), (1., Color::BLUE.to_rgba8_srgb())]); assert_eq!(stops, vec![(0., SRGBA8::from(Color::GREEN)), (1., SRGBA8::from(Color::BLUE))]);
assert!(transform.transform_point2(gradient.start).abs_diff_eq(DVec2::new(2., 3.), 1e-10)); assert!(transform.transform_point2(gradient.start).abs_diff_eq(DVec2::new(2., 3.), 1e-10));
assert!(transform.transform_point2(gradient.end).abs_diff_eq(DVec2::new(24., 4.), 1e-10)); assert!(transform.transform_point2(gradient.end).abs_diff_eq(DVec2::new(24., 4.), 1e-10));
} }
@@ -2215,7 +2217,7 @@ mod test_gradient {
let positions: Vec<f64> = stops.iter().map(|stop| stop.position).collect(); let positions: Vec<f64> = stops.iter().map(|stop| stop.position).collect();
assert_stops_at_positions(&positions, &[0., 0.25, 1.], 0.1); assert_stops_at_positions(&positions, &[0., 0.25, 1.], 0.1);
let middle_color = stops.color[1].to_rgba8_srgb(); let middle_color = SRGBA8::from(stops.color[1]);
// Simulate dragging the middle stop to position 0.8 // Simulate dragging the middle stop to position 0.8
let click_position = DVec2::new(25., 0.); let click_position = DVec2::new(25., 0.);
@@ -2256,9 +2258,9 @@ mod test_gradient {
assert_stops_at_positions(&updated_positions, &[0., 0.8, 1.], 0.1); assert_stops_at_positions(&updated_positions, &[0., 0.8, 1.], 0.1);
// Colors should maintain their associations with the stop points // Colors should maintain their associations with the stop points
assert_eq!(updated_stops.color[0].to_rgba8_srgb(), Color::GREEN.to_rgba8_srgb()); assert_eq!(SRGBA8::from(updated_stops.color[0]), SRGBA8::from(Color::GREEN));
assert_eq!(updated_stops.color[1].to_rgba8_srgb(), middle_color); assert_eq!(SRGBA8::from(updated_stops.color[1]), middle_color);
assert_eq!(updated_stops.color[2].to_rgba8_srgb(), Color::BLUE.to_rgba8_srgb()); assert_eq!(SRGBA8::from(updated_stops.color[2]), SRGBA8::from(Color::BLUE));
} }
#[tokio::test] #[tokio::test]
@@ -2495,8 +2497,8 @@ mod test_gradient {
assert_eq!(updated.stops.len(), 3, "Stop count should be preserved"); assert_eq!(updated.stops.len(), 3, "Stop count should be preserved");
assert_stops_at_positions(&updated.stops.position, &[0., 0.5, 1.], 1e-10); assert_stops_at_positions(&updated.stops.position, &[0., 0.5, 1.], 1e-10);
assert_eq!(updated.stops.color[0].to_rgba8_srgb(), Color::RED.to_rgba8_srgb(), "First stop color should be preserved"); assert_eq!(SRGBA8::from(updated.stops.color[0]), SRGBA8::from(Color::RED), "First stop color should be preserved");
assert_eq!(updated.stops.color[1].to_rgba8_srgb(), Color::GREEN.to_rgba8_srgb(), "Middle stop color should be preserved"); assert_eq!(SRGBA8::from(updated.stops.color[1]), SRGBA8::from(Color::GREEN), "Middle stop color should be preserved");
assert_eq!(updated.stops.color[2].to_rgba8_srgb(), Color::BLUE.to_rgba8_srgb(), "Last stop color should be preserved"); assert_eq!(SRGBA8::from(updated.stops.color[2]), SRGBA8::from(Color::BLUE), "Last stop color should be preserved");
} }
} }
@@ -2850,13 +2850,10 @@ impl Fsm for PathToolFsmState {
let layer = graph_modification_utils::new_custom(NodeId::new(), nodes, parent, responses); let layer = graph_modification_utils::new_custom(NodeId::new(), nodes, parent, responses);
// Defaults chosen because the pasted geometry has no inherent associated style // Defaults chosen because the pasted geometry has no inherent associated style
let stroke_color = Color::BLACK; let stroke = graphene_std::vector::style::Stroke::new(Some(Color::BLACK), DEFAULT_STROKE_WIDTH);
let fill_color = Color::WHITE;
let stroke = graphene_std::vector::style::Stroke::new(Some(stroke_color.to_gamma_srgb()), DEFAULT_STROKE_WIDTH);
responses.add(GraphOperationMessage::StrokeSet { layer, stroke }); responses.add(GraphOperationMessage::StrokeSet { layer, stroke });
let fill = graphene_std::vector::style::Fill::solid(fill_color.to_gamma_srgb()); let fill = graphene_std::vector::style::Fill::solid(Color::WHITE);
responses.add(GraphOperationMessage::FillSet { layer, fill }); responses.add(GraphOperationMessage::FillSet { layer, fill });
new_layers.push(layer); new_layers.push(layer);
@@ -154,7 +154,7 @@ impl LayoutHolder for PenTool {
}, },
|color: &ColorInput| { |color: &ColorInput| {
PenToolMessage::UpdateOptions { PenToolMessage::UpdateOptions {
options: PenOptionsUpdate::FillColor(color.value.clone()), options: PenOptionsUpdate::FillColor(FillChoice::from(&color.value)),
} }
.into() .into()
}, },
@@ -184,7 +184,7 @@ impl LayoutHolder for PenTool {
}, },
|color: &ColorInput| { |color: &ColorInput| {
PenToolMessage::UpdateOptions { PenToolMessage::UpdateOptions {
options: PenOptionsUpdate::StrokeColor(color.value.as_solid()), options: PenOptionsUpdate::StrokeColor(color.value.as_solid().map(Color::from)),
} }
.into() .into()
}, },
@@ -248,7 +248,7 @@ impl LayoutHolder for SelectTool {
}, },
|color: &ColorInput| { |color: &ColorInput| {
SelectToolMessage::SelectOptions { SelectToolMessage::SelectOptions {
options: SelectOptionsUpdate::FillColor(color.value.clone()), options: SelectOptionsUpdate::FillColor(FillChoice::from(&color.value)),
} }
.into() .into()
}, },
@@ -278,7 +278,7 @@ impl LayoutHolder for SelectTool {
}, },
|color: &ColorInput| { |color: &ColorInput| {
SelectToolMessage::SelectOptions { SelectToolMessage::SelectOptions {
options: SelectOptionsUpdate::StrokeColor(color.value.as_solid()), options: SelectOptionsUpdate::StrokeColor(color.value.as_solid().map(Color::from)),
} }
.into() .into()
}, },
@@ -467,7 +467,7 @@ impl LayoutHolder for ShapeTool {
}, },
|color: &ColorInput| { |color: &ColorInput| {
ShapeToolMessage::UpdateOptions { ShapeToolMessage::UpdateOptions {
options: ShapeOptionsUpdate::FillColor(color.value.clone()), options: ShapeOptionsUpdate::FillColor(FillChoice::from(&color.value)),
} }
.into() .into()
}, },
@@ -498,7 +498,7 @@ impl LayoutHolder for ShapeTool {
}, },
|color: &ColorInput| { |color: &ColorInput| {
ShapeToolMessage::UpdateOptions { ShapeToolMessage::UpdateOptions {
options: ShapeOptionsUpdate::StrokeColor(color.value.as_solid()), options: ShapeOptionsUpdate::StrokeColor(color.value.as_solid().map(Color::from)),
} }
.into() .into()
}, },
@@ -105,7 +105,7 @@ impl LayoutHolder for SplineTool {
}, },
|color: &ColorInput| { |color: &ColorInput| {
SplineToolMessage::UpdateOptions { SplineToolMessage::UpdateOptions {
options: SplineOptionsUpdate::FillColor(color.value.clone()), options: SplineOptionsUpdate::FillColor(FillChoice::from(&color.value)),
} }
.into() .into()
}, },
@@ -135,7 +135,7 @@ impl LayoutHolder for SplineTool {
}, },
|color: &ColorInput| { |color: &ColorInput| {
SplineToolMessage::UpdateOptions { SplineToolMessage::UpdateOptions {
options: SplineOptionsUpdate::StrokeColor(color.value.as_solid()), options: SplineOptionsUpdate::StrokeColor(color.value.as_solid().map(Color::from)),
} }
.into() .into()
}, },
@@ -9,7 +9,7 @@ use crate::messages::portfolio::document::utility_types::network_interface::Inpu
use crate::messages::portfolio::utility_types::{CachedData, FontCatalog, FontCatalogStyle}; use crate::messages::portfolio::utility_types::{CachedData, FontCatalog, FontCatalogStyle};
use crate::messages::tool::common_functionality::auto_panning::AutoPanning; use crate::messages::tool::common_functionality::auto_panning::AutoPanning;
use crate::messages::tool::common_functionality::color_selector::{ use crate::messages::tool::common_functionality::color_selector::{
ToolColorOptions, apply_fill_only_color_pick, apply_fill_only_enabled, refresh_slot_working_color, selection_changed_since_last_sync, solid_gamma, sync_fill_only, ToolColorOptions, apply_fill_only_color_pick, apply_fill_only_enabled, refresh_slot_working_color, selection_changed_since_last_sync, solid, sync_fill_only,
}; };
use crate::messages::tool::common_functionality::graph_modification_utils; use crate::messages::tool::common_functionality::graph_modification_utils;
use crate::messages::tool::common_functionality::resize::Resize; use crate::messages::tool::common_functionality::resize::Resize;
@@ -20,9 +20,10 @@ use crate::messages::tool::utility_types::ToolRefreshOptions;
use graph_craft::document::value::TaggedValue; use graph_craft::document::value::TaggedValue;
use graph_craft::document::{NodeId, NodeInput}; use graph_craft::document::{NodeId, NodeInput};
use graphene_std::choice_type::ChoiceTypeStatic; use graphene_std::choice_type::ChoiceTypeStatic;
use graphene_std::color::SRGBA8;
use graphene_std::renderer::Quad; use graphene_std::renderer::Quad;
use graphene_std::text::{Font, FontCache, TextAlign, TypesettingConfig, lines_clipping}; use graphene_std::text::{Font, FontCache, TextAlign, TypesettingConfig, lines_clipping};
use graphene_std::vector::style::{Fill, FillChoice}; use graphene_std::vector::style::{Fill, FillChoice, FillChoiceUI};
use graphene_std::{Color, NodeInputDecleration}; use graphene_std::{Color, NodeInputDecleration};
#[derive(Default, ExtractField)] #[derive(Default, ExtractField)]
@@ -269,12 +270,12 @@ impl TextTool {
fn layout(&self, font_catalog: &FontCatalog, document: &DocumentMessageHandler) -> Layout { fn layout(&self, font_catalog: &FontCatalog, document: &DocumentMessageHandler) -> Layout {
let mut widgets = vec![ let mut widgets = vec![
ColorInput::new(self.options.fill.fill_choice.clone().unwrap_or(graphene_std::vector::style::FillChoice::None)) ColorInput::new(FillChoiceUI::from(self.options.fill.fill_choice.as_ref().unwrap_or(&FillChoice::None)))
.mixed(self.options.fill.fill_choice.is_none()) .mixed(self.options.fill.fill_choice.is_none())
.narrow(true) .narrow(true)
.on_update(|color: &ColorInput| { .on_update(|color: &ColorInput| {
TextToolMessage::UpdateOptions { TextToolMessage::UpdateOptions {
options: TextOptionsUpdate::FillColor(color.value.clone()), options: TextOptionsUpdate::FillColor(FillChoice::from(&color.value)),
} }
.into() .into()
}) })
@@ -295,7 +296,7 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for Text
// reset the displayed fill color so the next activation starts fresh from the current working color. // reset the displayed fill color so the next activation starts fresh from the current working color.
// Guarded on `Ready` so Esc-mid-editing (which also fires Abort) doesn't wipe the user's customized fill option. // Guarded on `Ready` so Esc-mid-editing (which also fires Abort) doesn't wipe the user's customized fill option.
if matches!(&message, ToolMessage::Text(TextToolMessage::Abort)) && self.fsm_state == TextToolFsmState::Ready { if matches!(&message, ToolMessage::Text(TextToolMessage::Abort)) && self.fsm_state == TextToolFsmState::Ready {
self.options.fill.fill_choice = Some(solid_gamma(context.global_tool_data.primary_color)); self.options.fill.fill_choice = Some(solid(context.global_tool_data.primary_color));
} }
let options = match message { let options = match message {
@@ -319,7 +320,7 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for Text
if can_edit_selected(context.document).is_some() { if can_edit_selected(context.document).is_some() {
sync_fill_only(&mut self.options.fill, true, context.global_tool_data.primary_color, context.document, selection_changed); sync_fill_only(&mut self.options.fill, true, context.global_tool_data.primary_color, context.document, selection_changed);
} else if selection_changed { } else if selection_changed {
self.options.fill.fill_choice = Some(solid_gamma(context.global_tool_data.primary_color)); self.options.fill.fill_choice = Some(solid(context.global_tool_data.primary_color));
self.options.fill.tracks_working_color = true; self.options.fill.tracks_working_color = true;
} }
// Text tool has no fill checkbox; keep enabled so new text never starts with `None` // Text tool has no fill checkbox; keep enabled so new text never starts with `None`
@@ -497,7 +498,7 @@ impl TextToolData {
text: editing_text.text.clone(), text: editing_text.text.clone(),
line_height_ratio: editing_text.typesetting.line_height_ratio, line_height_ratio: editing_text.typesetting.line_height_ratio,
font_size: editing_text.typesetting.font_size, font_size: editing_text.typesetting.font_size,
color: editing_text.color.map_or("#000000".to_string(), |color| format!("#{}", color.to_rgba_hex_srgb())), color: editing_text.color.map_or("#000000".to_string(), |color| SRGBA8::from(color).to_css_hex()),
font_data: font_cache.get(&editing_text.font).map(|(data, _)| data.clone()).unwrap_or_default().into(), font_data: font_cache.get(&editing_text.font).map(|(data, _)| data.clone()).unwrap_or_default().into(),
transform: editing_text.transform.to_cols_array(), transform: editing_text.transform.to_cols_array(),
max_width: editing_text.typesetting.max_width, max_width: editing_text.typesetting.max_width,
@@ -574,7 +575,7 @@ impl TextToolData {
}); });
responses.add(GraphOperationMessage::FillSet { responses.add(GraphOperationMessage::FillSet {
layer: self.layer, layer: self.layer,
fill: if let Some(color) = editing_text.color { Fill::Solid(color.to_gamma_srgb()) } else { Fill::None }, fill: if let Some(color) = editing_text.color { Fill::Solid(color) } else { Fill::None },
}); });
let transform = editing_text.transform; let transform = editing_text.transform;
self.editing_text = Some(editing_text); self.editing_text = Some(editing_text);
+2 -3
View File
@@ -14,6 +14,7 @@ use crate::messages::preferences::PreferencesMessageHandler;
use crate::messages::prelude::*; use crate::messages::prelude::*;
use crate::messages::tool::common_functionality::shapes::shape_utility::ShapeType; use crate::messages::tool::common_functionality::shapes::shape_utility::ShapeType;
use crate::node_graph_executor::NodeGraphExecutor; use crate::node_graph_executor::NodeGraphExecutor;
use graphene_std::color::SRGBA8;
use graphene_std::raster::color::Color; use graphene_std::raster::color::Color;
use std::borrow::Cow; use std::borrow::Cow;
use std::fmt::{self, Debug}; use std::fmt::{self, Debug};
@@ -128,9 +129,7 @@ pub struct DocumentToolData {
impl DocumentToolData { impl DocumentToolData {
pub fn update_working_colors(&self, responses: &mut VecDeque<Message>) { pub fn update_working_colors(&self, responses: &mut VecDeque<Message>) {
let layout = Layout(vec![ let layout = Layout(vec![
LayoutGroup::row(vec![ LayoutGroup::row(vec![WorkingColorsInput::new(SRGBA8::from(self.primary_color), SRGBA8::from(self.secondary_color)).widget_instance()]),
WorkingColorsInput::new(self.primary_color.to_gamma_srgb(), self.secondary_color.to_gamma_srgb()).widget_instance(),
]),
LayoutGroup::row(vec![ LayoutGroup::row(vec![
IconButton::new("SwapVertical", 16) IconButton::new("SwapVertical", 16)
.tooltip_label("Swap Working Colors") .tooltip_label("Swap Working Colors")
+16 -1
View File
@@ -6,6 +6,7 @@ use graph_craft::document::value::{RenderOutput, RenderOutputType, TaggedValue};
use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput}; use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput};
use graph_craft::proto::GraphErrors; use graph_craft::proto::GraphErrors;
use graphene_std::application_io::{NodeGraphUpdateMessage, RenderConfig, TimingInformation}; use graphene_std::application_io::{NodeGraphUpdateMessage, RenderConfig, TimingInformation};
use graphene_std::color::SRGBA8;
use graphene_std::raster::{CPU, Raster}; use graphene_std::raster::{CPU, Raster};
use graphene_std::renderer::RenderMetadata; use graphene_std::renderer::RenderMetadata;
use graphene_std::text::FontCache; use graphene_std::text::FontCache;
@@ -398,7 +399,21 @@ impl NodeGraphExecutor {
match render_output.data { match render_output.data {
RenderOutputType::Svg { svg, image_data } => { RenderOutputType::Svg { svg, image_data } => {
// Send to frontend // Convert each linear-light `Image<Color>` into the JS-boundary `Image<SRGBA8>` form (gamma byte channels) before dispatching.
let image_data = image_data
.into_iter()
.map(|(id, image)| {
(
id,
graphene_std::raster::Image {
width: image.width,
height: image.height,
data: image.data.iter().map(|&c| SRGBA8::from(c)).collect(),
base64_string: image.base64_string,
},
)
})
.collect();
responses.add(FrontendMessage::UpdateImageData { image_data }); responses.add(FrontendMessage::UpdateImageData { image_data });
responses.add(FrontendMessage::UpdateDocumentArtwork { svg }); responses.add(FrontendMessage::UpdateDocumentArtwork { svg });
} }
@@ -5,14 +5,14 @@
import LayoutRow from "/src/components/layout/LayoutRow.svelte"; import LayoutRow from "/src/components/layout/LayoutRow.svelte";
import WidgetLayout from "/src/components/widgets/WidgetLayout.svelte"; import WidgetLayout from "/src/components/widgets/WidgetLayout.svelte";
import type { ColorPickerCallbacks, ColorPickerStore } from "/src/stores/color-picker"; import type { ColorPickerCallbacks, ColorPickerStore } from "/src/stores/color-picker";
import type { EditorWrapper, FillChoice, MenuDirection } from "/wrapper/pkg/graphite_wasm_wrapper"; import type { EditorWrapper, FillChoiceUI, MenuDirection } from "/wrapper/pkg/graphite_wasm_wrapper";
const dispatch = createEventDispatcher<{ colorOrGradient: FillChoice; startHistoryTransaction: undefined; commitHistoryTransaction: undefined }>(); const dispatch = createEventDispatcher<{ colorOrGradient: FillChoiceUI; startHistoryTransaction: undefined; commitHistoryTransaction: undefined }>();
const editor = getContext<EditorWrapper>("editor"); const editor = getContext<EditorWrapper>("editor");
const colorPickerStore = getContext<ColorPickerStore>("colorPicker"); const colorPickerStore = getContext<ColorPickerStore>("colorPicker");
export let colorOrGradient: FillChoice; export let colorOrGradient: FillChoiceUI;
export let allowNone = false; export let allowNone = false;
// export let allowTransparency = false; // TODO: Implement // export let allowTransparency = false; // TODO: Implement
export let disabled = false; export let disabled = false;
+13 -13
View File
@@ -12,12 +12,12 @@
import type { DocumentStore } from "/src/stores/document"; import type { DocumentStore } from "/src/stores/document";
import type { SubscriptionsRouter } from "/src/subscriptions-router"; import type { SubscriptionsRouter } from "/src/subscriptions-router";
import type { MessageBody } from "/src/subscriptions-router"; import type { MessageBody } from "/src/subscriptions-router";
import { fillChoiceColor, createColor } from "/src/utility-functions/colors"; import { fillChoiceUIColor, createSRgba8 } from "/src/utility-functions/colors";
import { pasteFile } from "/src/utility-functions/files"; import { pasteFile } from "/src/utility-functions/files";
import { textInputCleanup } from "/src/utility-functions/keyboard-entry"; import { textInputCleanup } from "/src/utility-functions/keyboard-entry";
import { rasterizeSVGCanvas } from "/src/utility-functions/rasterization"; import { rasterizeSVGCanvas } from "/src/utility-functions/rasterization";
import { setupViewportResizeObserver } from "/src/utility-functions/viewports"; import { setupViewportResizeObserver } from "/src/utility-functions/viewports";
import type { Color, EditorWrapper, MenuDirection, MouseCursorIcon } from "/wrapper/pkg/graphite_wasm_wrapper"; import type { EditorWrapper, MenuDirection, MouseCursorIcon, SRGBA8 } from "/wrapper/pkg/graphite_wasm_wrapper";
let rulerHorizontal: RulerInput | undefined; let rulerHorizontal: RulerInput | undefined;
let rulerVertical: RulerInput | undefined; let rulerVertical: RulerInput | undefined;
@@ -70,7 +70,7 @@
let cursorEyedropperPreviewColorSecondary = ""; let cursorEyedropperPreviewColorSecondary = "";
// Gradient stop color picker // Gradient stop color picker
let gradientStopPickerColor: Color | undefined = undefined; let gradientStopPickerColor: SRGBA8 | undefined = undefined;
let gradientStopPickerPosition: { x: number; y: number } | undefined = undefined; let gradientStopPickerPosition: { x: number; y: number } | undefined = undefined;
// Canvas dimensions // Canvas dimensions
@@ -223,7 +223,7 @@
mousePosition: [number, number] | undefined, mousePosition: [number, number] | undefined,
colorPrimary: string, colorPrimary: string,
colorSecondary: string, colorSecondary: string,
): Promise<[number, number, number] | undefined> { ): Promise<SRGBA8 | undefined> {
if (mousePosition === undefined) { if (mousePosition === undefined) {
cursorEyedropper = false; cursorEyedropper = false;
return undefined; return undefined;
@@ -275,14 +275,14 @@
}; };
})(); })();
const hex = [centerPixel.r, centerPixel.g, centerPixel.b].map((x) => x.toString(16).padStart(2, "0")).join(""); const hex = [centerPixel.r, centerPixel.g, centerPixel.b].map((x) => x.toString(16).padStart(2, "0")).join("");
const rgb: [number, number, number] = [centerPixel.r / 255, centerPixel.g / 255, centerPixel.b / 255]; const sRgba8: SRGBA8 = { red: centerPixel.r, green: centerPixel.g, blue: centerPixel.b, alpha: 255 };
cursorEyedropperPreviewColorChoice = "#" + hex; cursorEyedropperPreviewColorChoice = "#" + hex;
cursorEyedropperPreviewColorPrimary = colorPrimary; cursorEyedropperPreviewColorPrimary = colorPrimary;
cursorEyedropperPreviewColorSecondary = colorSecondary; cursorEyedropperPreviewColorSecondary = colorSecondary;
cursorEyedropperPreviewImageData = preview; cursorEyedropperPreviewImageData = preview;
return rgb; return sRgba8;
} }
// Update scrollbars and rulers // Update scrollbars and rulers
@@ -477,11 +477,11 @@
const { image, mousePosition, primaryColor, secondaryColor, setColorChoice } = data; const { image, mousePosition, primaryColor, secondaryColor, setColorChoice } = data;
const imageData = image !== undefined ? new ImageData(new Uint8ClampedArray(image.data), image.width, image.height) : undefined; const imageData = image !== undefined ? new ImageData(new Uint8ClampedArray(image.data), image.width, image.height) : undefined;
const rgb = await updateEyedropperSamplingState(imageData, mousePosition, primaryColor, secondaryColor); const sRgba8 = await updateEyedropperSamplingState(imageData, mousePosition, primaryColor, secondaryColor);
if (setColorChoice && rgb) { if (setColorChoice && sRgba8) {
if (setColorChoice === "Primary") editor.updatePrimaryColor(...rgb, 1); if (setColorChoice === "Primary") editor.updatePrimaryColor(sRgba8);
if (setColorChoice === "Secondary") editor.updateSecondaryColor(...rgb, 1); if (setColorChoice === "Secondary") editor.updateSecondaryColor(sRgba8);
} }
}); });
@@ -668,10 +668,10 @@
gradientStopPickerColor = undefined; gradientStopPickerColor = undefined;
} }
}} }}
colorOrGradient={{ Solid: gradientStopPickerColor || createColor(0, 0, 0, 1) }} colorOrGradient={{ Solid: gradientStopPickerColor || createSRgba8(0, 0, 0, 255) }}
on:colorOrGradient={({ detail }) => { on:colorOrGradient={({ detail }) => {
const color = fillChoiceColor(detail); const color = fillChoiceUIColor(detail);
if (color) editor.updateGradientStopColor(color.red, color.green, color.blue, color.alpha); if (color) editor.updateGradientStopColor(color);
}} }}
on:startHistoryTransaction={() => editor.startGradientStopColorTransaction()} on:startHistoryTransaction={() => editor.startGradientStopColorTransaction()}
on:commitHistoryTransaction={() => editor.commitGradientStopColorTransaction()} on:commitHistoryTransaction={() => editor.commitGradientStopColorTransaction()}
@@ -26,7 +26,7 @@
import ShortcutLabel from "/src/components/widgets/labels/ShortcutLabel.svelte"; import ShortcutLabel from "/src/components/widgets/labels/ShortcutLabel.svelte";
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte"; import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
import type { ColorPickerStore } from "/src/stores/color-picker"; import type { ColorPickerStore } from "/src/stores/color-picker";
import { parseFillChoice } from "/src/utility-functions/colors"; import { parseFillChoiceUI } from "/src/utility-functions/colors";
import type { EditorWrapper, LayoutTarget, Widget, WidgetInstance } from "/wrapper/pkg/graphite_wasm_wrapper"; import type { EditorWrapper, LayoutTarget, Widget, WidgetInstance } from "/wrapper/pkg/graphite_wasm_wrapper";
// Extract the discriminant key names from the Widget tagged enum union (e.g. "TextButton" | "CheckboxInput" | ...) // Extract the discriminant key names from the Widget tagged enum union (e.g. "TextButton" | "CheckboxInput" | ...)
@@ -134,7 +134,7 @@
component: ColorInput, component: ColorInput,
getProps: (props, index) => ({ getProps: (props, index) => ({
...props, ...props,
value: parseFillChoice(props.value), value: parseFillChoiceUI(props.value),
$$events: { $$events: {
value: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false), value: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false),
startHistoryTransaction: () => widgetValueCommit(index, props.value), startHistoryTransaction: () => widgetValueCommit(index, props.value),
@@ -146,7 +146,7 @@
getProps: (props, index) => ({ getProps: (props, index) => ({
...props, ...props,
$$events: { $$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). // The widget dispatches `"None"` or a bare `SRGBA8`, wrap the color in `{ Solid: ... }` so the payload matches Rust's `FillChoiceUI` shape (which the `Preset` variant expects).
preset: (e: CustomEvent) => widgetValueCommitAndUpdate(index, { Preset: e.detail === "None" ? "None" : { Solid: e.detail } }, true), preset: (e: CustomEvent) => widgetValueCommitAndUpdate(index, { Preset: e.detail === "None" ? "None" : { Solid: e.detail } }, true),
eyedropperColorCode: (e: CustomEvent) => widgetValueCommitAndUpdate(index, { EyedropperColorCode: e.detail }, true), eyedropperColorCode: (e: CustomEvent) => widgetValueCommitAndUpdate(index, { EyedropperColorCode: e.detail }, true),
}, },
@@ -4,12 +4,12 @@
import LayoutRow from "/src/components/layout/LayoutRow.svelte"; import LayoutRow from "/src/components/layout/LayoutRow.svelte";
import IconButton from "/src/components/widgets/buttons/IconButton.svelte"; import IconButton from "/src/components/widgets/buttons/IconButton.svelte";
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte"; import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
import type { Color } from "/wrapper/pkg/graphite_wasm_wrapper"; import type { SRGBA8 } from "/wrapper/pkg/graphite_wasm_wrapper";
const dispatch = createEventDispatcher<{ swap: undefined }>(); const dispatch = createEventDispatcher<{ swap: undefined }>();
export let newColor: Color | undefined; export let newColor: SRGBA8 | undefined;
export let oldColor: Color | undefined; export let oldColor: SRGBA8 | undefined;
export let newColorCSS: string; export let newColorCSS: string;
export let newColorContrasting: string; export let newColorContrasting: string;
export let oldColorCSS: string; export let oldColorCSS: string;
@@ -21,7 +21,7 @@
export let outlineAmount: number; export let outlineAmount: number;
$: outlined = outlineAmount > 0.0001; $: outlined = outlineAmount > 0.0001;
$: transparency = (newColor?.alpha ?? 1) < 1 || (oldColor?.alpha ?? 1) < 1; $: transparency = (newColor?.alpha ?? 255) < 255 || (oldColor?.alpha ?? 255) < 255;
</script> </script>
<LayoutRow <LayoutRow
@@ -2,13 +2,13 @@
import { createEventDispatcher } from "svelte"; import { createEventDispatcher } from "svelte";
import ColorPicker from "/src/components/floating-menus/ColorPicker.svelte"; import ColorPicker from "/src/components/floating-menus/ColorPicker.svelte";
import LayoutCol from "/src/components/layout/LayoutCol.svelte"; import LayoutCol from "/src/components/layout/LayoutCol.svelte";
import { contrastingOutlineFactor, fillChoiceColor, fillChoiceGradientStops } from "/src/utility-functions/colors"; import { contrastingOutlineFactor, fillChoiceUIColor, fillChoiceUIGradientStops } from "/src/utility-functions/colors";
import type { FillChoice, MenuDirection, ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper"; import type { FillChoiceUI, MenuDirection, ActionShortcut } from "/wrapper/pkg/graphite_wasm_wrapper";
const dispatch = createEventDispatcher<{ value: FillChoice; startHistoryTransaction: undefined }>(); const dispatch = createEventDispatcher<{ value: FillChoiceUI; startHistoryTransaction: undefined }>();
// Content // Content
export let value: FillChoice; export let value: FillChoiceUI;
export let chosenGradient: string | undefined = undefined; export let chosenGradient: string | undefined = undefined;
export let allowNone = false; export let allowNone = false;
// export let allowTransparency = false; // TODO: Implement // export let allowTransparency = false; // TODO: Implement
@@ -29,10 +29,10 @@
$: outlineFactor = contrastingOutlineFactor(value, "--color-3-darkgray", 0.01); $: outlineFactor = contrastingOutlineFactor(value, "--color-3-darkgray", 0.01);
$: outlined = outlineFactor > 0.0001; $: outlined = outlineFactor > 0.0001;
$: gradientStops = fillChoiceGradientStops(value); $: gradientStops = fillChoiceUIGradientStops(value);
$: solidColor = fillChoiceColor(value); $: solidColor = fillChoiceUIColor(value);
$: none = value === "None"; $: none = value === "None";
$: transparency = gradientStops ? gradientStops.color.some((color) => color.alpha < 1) : solidColor ? solidColor.alpha < 1 : false; $: transparency = gradientStops ? gradientStops.color.some((color) => color.alpha < 255) : solidColor ? solidColor.alpha < 255 : false;
</script> </script>
<LayoutCol <LayoutCol
@@ -3,20 +3,20 @@
import LayoutRow from "/src/components/layout/LayoutRow.svelte"; import LayoutRow from "/src/components/layout/LayoutRow.svelte";
import IconButton from "/src/components/widgets/buttons/IconButton.svelte"; import IconButton from "/src/components/widgets/buttons/IconButton.svelte";
import Separator from "/src/components/widgets/labels/Separator.svelte"; import Separator from "/src/components/widgets/labels/Separator.svelte";
import { createColor } from "/src/utility-functions/colors"; import { createSRgba8 } from "/src/utility-functions/colors";
import type { Color } from "/wrapper/pkg/graphite_wasm_wrapper"; import type { SRGBA8 } from "/wrapper/pkg/graphite_wasm_wrapper";
type PresetColor = "Black" | "White" | "Red" | "Yellow" | "Green" | "Cyan" | "Blue" | "Magenta"; type PresetColor = "Black" | "White" | "Red" | "Yellow" | "Green" | "Cyan" | "Blue" | "Magenta";
const PURE_COLORS: Record<PresetColor, [number, number, number]> = { const PURE_COLORS: Record<PresetColor, [number, number, number]> = {
Black: [0, 0, 0], Black: [0, 0, 0],
White: [1, 1, 1], White: [255, 255, 255],
Red: [1, 0, 0], Red: [255, 0, 0],
Yellow: [1, 1, 0], Yellow: [255, 255, 0],
Green: [0, 1, 0], Green: [0, 255, 0],
Cyan: [0, 1, 1], Cyan: [0, 255, 255],
Blue: [0, 0, 1], Blue: [0, 0, 255],
Magenta: [1, 0, 1], Magenta: [255, 0, 255],
}; };
const PURE_COLORS_GRAYABLE: [PresetColor, string, string][] = [ const PURE_COLORS_GRAYABLE: [PresetColor, string, string][] = [
["Red", "#ff0000", "#4c4c4c"], ["Red", "#ff0000", "#4c4c4c"],
@@ -28,7 +28,7 @@
]; ];
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
preset: Color | "None"; preset: SRGBA8 | "None";
eyedropperColorCode: string; eyedropperColorCode: string;
}>(); }>();
@@ -37,7 +37,7 @@
function pickPreset(preset: PresetColor | "None") { function pickPreset(preset: PresetColor | "None") {
if (disabled) return; if (disabled) return;
dispatch("preset", preset === "None" ? "None" : createColor(...PURE_COLORS[preset], 1)); dispatch("preset", preset === "None" ? "None" : createSRgba8(...PURE_COLORS[preset], 255));
} }
// TODO: Replace this temporary usage of the browser eyedropper API, that only works in Chromium-based browsers, with the custom color sampler system used by the Eyedropper tool // TODO: Replace this temporary usage of the browser eyedropper API, that only works in Chromium-based browsers, with the custom color sampler system used by the Eyedropper tool
@@ -3,7 +3,7 @@
import LayoutCol from "/src/components/layout/LayoutCol.svelte"; import LayoutCol from "/src/components/layout/LayoutCol.svelte";
import LayoutRow from "/src/components/layout/LayoutRow.svelte"; import LayoutRow from "/src/components/layout/LayoutRow.svelte";
import type { TooltipStore } from "/src/stores/tooltip"; import type { TooltipStore } from "/src/stores/tooltip";
import { colorContrastingColor, colorOpaque, colorToHexNoAlpha, colorToRgbCSS, createColor, createColorFromHSVA } from "/src/utility-functions/colors"; import { sRgba8ContrastingColor, sRgba8Opaque, sRgba8ToHexNoAlpha, sRgba8ToRgbCSS, createSRgba8, createSRgba8FromHsva } from "/src/utility-functions/colors";
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
update: { hue: number; saturation: number; value: number; alpha: number }; update: { hue: number; saturation: number; value: number; alpha: number };
@@ -184,20 +184,20 @@
removeEvents(); removeEvents();
}); });
$: newColor = isNone ? undefined : createColorFromHSVA(hue, saturation, value, alpha); $: newColor = isNone ? undefined : createSRgba8FromHsva(hue, saturation, value, alpha);
$: opaqueHueColor = createColorFromHSVA(hue, 1, 1, 1); $: opaqueHueColor = createSRgba8FromHsva(hue, 1, 1, 1);
$: opaqueColorOnly = newColor ? colorOpaque(newColor) : createColor(0, 0, 0, 1); $: opaqueColorOnly = newColor ? sRgba8Opaque(newColor) : createSRgba8(0, 0, 0, 255);
</script> </script>
<LayoutRow <LayoutRow
class="visual-color-pickers-input" class="visual-color-pickers-input"
classes={{ disabled }} classes={{ disabled }}
styles={{ styles={{
"--hue-color": colorToRgbCSS(opaqueHueColor), "--hue-color": sRgba8ToRgbCSS(opaqueHueColor),
"--hue-color-contrasting": colorContrastingColor(opaqueHueColor), "--hue-color-contrasting": sRgba8ContrastingColor(opaqueHueColor),
"--opaque-color": colorToHexNoAlpha(opaqueColorOnly), "--opaque-color": sRgba8ToHexNoAlpha(opaqueColorOnly),
"--opaque-color-contrasting": colorContrastingColor(opaqueColorOnly), "--opaque-color-contrasting": sRgba8ContrastingColor(opaqueColorOnly),
"--new-color-contrasting": colorContrastingColor(newColor), "--new-color-contrasting": sRgba8ContrastingColor(newColor),
}} }}
> >
{@const hueDescription = "The shade along the spectrum of the rainbow."} {@const hueDescription = "The shade along the spectrum of the rainbow."}
@@ -3,14 +3,14 @@
import ColorPicker from "/src/components/floating-menus/ColorPicker.svelte"; import ColorPicker from "/src/components/floating-menus/ColorPicker.svelte";
import LayoutCol from "/src/components/layout/LayoutCol.svelte"; import LayoutCol from "/src/components/layout/LayoutCol.svelte";
import LayoutRow from "/src/components/layout/LayoutRow.svelte"; import LayoutRow from "/src/components/layout/LayoutRow.svelte";
import { fillChoiceColor, colorToRgbaCSS } from "/src/utility-functions/colors"; import { fillChoiceUIColor, sRgba8ToRgbaCSS } from "/src/utility-functions/colors";
import type { Color, EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper"; import type { SRGBA8, EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
const editor = getContext<EditorWrapper>("editor"); const editor = getContext<EditorWrapper>("editor");
// Content // Content
export let primary: Color; export let primary: SRGBA8;
export let secondary: Color; export let secondary: SRGBA8;
let primaryOpen = false; let primaryOpen = false;
let secondaryOpen = false; let secondaryOpen = false;
@@ -25,37 +25,37 @@
secondaryOpen = true; secondaryOpen = true;
} }
function primaryColorChanged(color: Color) { function primaryColorChanged(color: SRGBA8) {
editor.updatePrimaryColor(color.red, color.green, color.blue, color.alpha); editor.updatePrimaryColor(color);
} }
function secondaryColorChanged(color: Color) { function secondaryColorChanged(color: SRGBA8) {
editor.updateSecondaryColor(color.red, color.green, color.blue, color.alpha); editor.updateSecondaryColor(color);
} }
</script> </script>
<LayoutCol class="working-colors-button"> <LayoutCol class="working-colors-button">
<LayoutRow class="primary swatch"> <LayoutRow class="primary swatch">
<button on:click={clickPrimarySwatch} class:open={primaryOpen} style:--swatch-color={colorToRgbaCSS(primary)} data-floating-menu-spawner data-block-hover-transfer tabindex="0"></button> <button on:click={clickPrimarySwatch} class:open={primaryOpen} style:--swatch-color={sRgba8ToRgbaCSS(primary)} data-floating-menu-spawner data-block-hover-transfer tabindex="0"></button>
<ColorPicker <ColorPicker
open={primaryOpen} open={primaryOpen}
on:open={({ detail }) => (primaryOpen = detail)} on:open={({ detail }) => (primaryOpen = detail)}
colorOrGradient={{ Solid: primary }} colorOrGradient={{ Solid: primary }}
on:colorOrGradient={({ detail }) => { on:colorOrGradient={({ detail }) => {
const color = fillChoiceColor(detail); const color = fillChoiceUIColor(detail);
if (color) primaryColorChanged(color); if (color) primaryColorChanged(color);
}} }}
direction="Right" direction="Right"
/> />
</LayoutRow> </LayoutRow>
<LayoutRow class="secondary swatch"> <LayoutRow class="secondary swatch">
<button on:click={clickSecondarySwatch} class:open={secondaryOpen} style:--swatch-color={colorToRgbaCSS(secondary)} data-floating-menu-spawner data-block-hover-transfer tabindex="0"></button> <button on:click={clickSecondarySwatch} class:open={secondaryOpen} style:--swatch-color={sRgba8ToRgbaCSS(secondary)} data-floating-menu-spawner data-block-hover-transfer tabindex="0"></button>
<ColorPicker <ColorPicker
open={secondaryOpen} open={secondaryOpen}
on:open={({ detail }) => (secondaryOpen = detail)} on:open={({ detail }) => (secondaryOpen = detail)}
colorOrGradient={{ Solid: secondary }} colorOrGradient={{ Solid: secondary }}
on:colorOrGradient={({ detail }) => { on:colorOrGradient={({ detail }) => {
const color = fillChoiceColor(detail); const color = fillChoiceUIColor(detail);
if (color) secondaryColorChanged(color); if (color) secondaryColorChanged(color);
}} }}
direction="Right" direction="Right"
+3 -3
View File
@@ -11,7 +11,7 @@
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte"; import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
import { panelDrag, startCrossPanelDrag, endCrossPanelDrag, updateCrossPanelHover, updateDockingHover } from "/src/stores/panel-drag"; import { panelDrag, startCrossPanelDrag, endCrossPanelDrag, updateCrossPanelHover, updateDockingHover } from "/src/stores/panel-drag";
import type { DockingEdge } from "/src/stores/panel-drag"; import type { DockingEdge } from "/src/stores/panel-drag";
import type { EditorWrapper, PanelType } from "/wrapper/pkg/graphite_wasm_wrapper"; import type { DockingSplitDirection, EditorWrapper, PanelType } from "/wrapper/pkg/graphite_wasm_wrapper";
const PANEL_COMPONENTS = { const PANEL_COMPONENTS = {
Welcome, Welcome,
@@ -40,7 +40,7 @@
export let emptySpaceAction: (() => void) | undefined = undefined; export let emptySpaceAction: (() => void) | undefined = undefined;
export let crossPanelDropAction: ((sourcePanelId: string, targetPanelId: string, insertIndex: number) => void) | undefined = undefined; export let crossPanelDropAction: ((sourcePanelId: string, targetPanelId: string, insertIndex: number) => void) | undefined = undefined;
export let groupDropAction: ((sourcePanelId: string, targetPanelId: string, insertIndex: number) => void) | undefined = undefined; export let groupDropAction: ((sourcePanelId: string, targetPanelId: string, insertIndex: number) => void) | undefined = undefined;
export let splitDropAction: ((targetPanelId: string, direction: DockingEdge, tabs: PanelType[], activeTabIndex: number) => void) | undefined = undefined; export let splitDropAction: ((targetPanelId: string, direction: DockingSplitDirection, tabs: PanelType[], activeTabIndex: number) => void) | undefined = undefined;
let className = ""; let className = "";
export { className as class }; export { className as class };
@@ -222,7 +222,7 @@
dropAction?.(panelId, crossPanelState.hoverDockingPanelId, Number.MAX_SAFE_INTEGER); dropAction?.(panelId, crossPanelState.hoverDockingPanelId, Number.MAX_SAFE_INTEGER);
} }
// Edge docking drop: create a new split adjacent to the target panel // Edge docking drop: create a new split adjacent to the target panel
else if (crossPanelState.active && crossPanelState.hoverDockingPanelId && crossPanelState.hoverDockingEdge) { else if (crossPanelState.active && crossPanelState.hoverDockingPanelId && crossPanelState.hoverDockingEdge && crossPanelState.hoverDockingEdge !== "Center") {
splitDropAction?.( splitDropAction?.(
crossPanelState.hoverDockingPanelId, crossPanelState.hoverDockingPanelId,
crossPanelState.hoverDockingEdge, crossPanelState.hoverDockingEdge,
@@ -4,7 +4,7 @@
import LayoutRow from "/src/components/layout/LayoutRow.svelte"; import LayoutRow from "/src/components/layout/LayoutRow.svelte";
import Panel from "/src/components/window/Panel.svelte"; import Panel from "/src/components/window/Panel.svelte";
import type { PortfolioStore } from "/src/stores/portfolio"; import type { PortfolioStore } from "/src/stores/portfolio";
import type { DocumentInfo, EditorWrapper, PanelGroupState, PanelLayoutSubdivision } from "/wrapper/pkg/graphite_wasm_wrapper"; import type { DockingSplitDirection, DocumentInfo, EditorWrapper, PanelGroupState, PanelLayoutSubdivision, PanelType } from "/wrapper/pkg/graphite_wasm_wrapper";
const MIN_PANEL_SIZE = 100; const MIN_PANEL_SIZE = 100;
const DOUBLE_CLICK_MILLISECONDS = 500; const DOUBLE_CLICK_MILLISECONDS = 500;
@@ -85,7 +85,7 @@
sizeOverrides = sizeOverrides; sizeOverrides = sizeOverrides;
const allSizes = children.map((child, i) => sizeOverrides[i] ?? child.size); const allSizes = children.map((child, i) => sizeOverrides[i] ?? child.size);
editor.setPanelGroupSizes(splitPath, allSizes); editor.setPanelGroupSizes(new Uint32Array(splitPath), new Float64Array(allSizes));
return; return;
} }
@@ -140,7 +140,7 @@
// Persist the resized sizes to the backend // Persist the resized sizes to the backend
if ("Split" in subdivision) { if ("Split" in subdivision) {
const allSizes = subdivision.Split.children.map((child, i) => sizeOverrides[i] ?? child.size); const allSizes = subdivision.Split.children.map((child, i) => sizeOverrides[i] ?? child.size);
editor.setPanelGroupSizes(splitPath, allSizes); editor.setPanelGroupSizes(new Uint32Array(splitPath), new Float64Array(allSizes));
} }
}; };
@@ -179,7 +179,7 @@
editor.moveAllPanelTabs(BigInt(sourcePanelId), BigInt(targetPanelId), insertIndex); editor.moveAllPanelTabs(BigInt(sourcePanelId), BigInt(targetPanelId), insertIndex);
} }
function splitDrop(targetPanelId: string, direction: string, tabs: string[], activeTabIndex: number) { function splitDrop(targetPanelId: string, direction: DockingSplitDirection, tabs: PanelType[], activeTabIndex: number) {
editor.splitPanelGroup(BigInt(targetPanelId), direction, tabs, activeTabIndex); editor.splitPanelGroup(BigInt(targetPanelId), direction, tabs, activeTabIndex);
} }
+4 -4
View File
@@ -2,10 +2,10 @@ import { writable } from "svelte/store";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import type { SubscriptionsRouter } from "/src/subscriptions-router"; import type { SubscriptionsRouter } from "/src/subscriptions-router";
import { patchLayout } from "/src/utility-functions/widgets"; import { patchLayout } from "/src/utility-functions/widgets";
import type { FillChoice, Layout } from "/wrapper/pkg/graphite_wasm_wrapper"; import type { FillChoiceUI, Layout } from "/wrapper/pkg/graphite_wasm_wrapper";
export type ColorPickerCallbacks = { export type ColorPickerCallbacks = {
onColorChanged?: (value: FillChoice) => void; onColorChanged?: (value: FillChoiceUI) => void;
onStartTransaction?: () => void; onStartTransaction?: () => void;
onCommitTransaction?: () => void; onCommitTransaction?: () => void;
}; };
@@ -40,9 +40,9 @@ export type ColorPickerStore = {
setDragging: (dragging: boolean) => void; setDragging: (dragging: boolean) => void;
}; };
// The Rust handler keeps a single shared layout per target, but multiple `<ColorPicker>` Svelte instances may be mounted across // The Rust handler keeps a single shared layout per target, but multiple `<ColorPicker />` Svelte instances may be mounted across
// the app (one per `ColorInput`/`WorkingColorsInput`/etc.). Subscribing to the layout target from each instance is destructive, // 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 `<ColorPicker>` instance // only the last-registered callback wins. So we maintain a single global subscription here and let each `<ColorPicker />` instance
// read from the resulting store and register its own per-open callbacks for color/transaction events. // read from the resulting store and register its own per-open callbacks for color/transaction events.
export function createColorPickerStore(subscriptions: SubscriptionsRouter): ColorPickerStore { export function createColorPickerStore(subscriptions: SubscriptionsRouter): ColorPickerStore {
destroyColorPickerStore(); destroyColorPickerStore();
+2 -2
View File
@@ -1,8 +1,8 @@
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import type { PanelType } from "/wrapper/pkg/graphite_wasm_wrapper"; import type { DockingSplitDirection, PanelType } from "/wrapper/pkg/graphite_wasm_wrapper";
export type DockingEdge = "Left" | "Right" | "Top" | "Bottom" | "Center"; export type DockingEdge = DockingSplitDirection | "Center";
export type PanelDragState = { export type PanelDragState = {
active: boolean; active: boolean;
+54 -57
View File
@@ -1,4 +1,4 @@
import type { Color, FillChoice, GradientStops } from "/wrapper/pkg/graphite_wasm_wrapper"; import type { FillChoiceUI, GradientStopsUI, SRGBA8 } 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 // 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
export type HSV = { h: number; s: number; v: number }; export type HSV = { h: number; s: number; v: number };
@@ -6,26 +6,33 @@ export type RGB = { r: number; g: number; b: number };
// COLOR FACTORY FUNCTIONS // COLOR FACTORY FUNCTIONS
export function createColor(red: number, green: number, blue: number, alpha: number): Color { export function createSRgba8(red: number, green: number, blue: number, alpha: number): SRGBA8 {
return { red, green, blue, alpha }; return { red, green, blue, alpha };
} }
export function createColorFromHSVA(h: number, s: number, v: number, a: number): Color { // Build an `SRGBA8` from HSVA components on the 0..1 range.
export function createSRgba8FromHsva(h: number, s: number, v: number, a: number): SRGBA8 {
const convert = (n: number): number => { const convert = (n: number): number => {
const k = (n + h * 6) % 6; const k = (n + h * 6) % 6;
return v - v * s * Math.max(Math.min(...[k, 4 - k, 1]), 0); return v - v * s * Math.max(Math.min(...[k, 4 - k, 1]), 0);
}; };
return { red: convert(5), green: convert(3), blue: convert(1), alpha: a }; return {
red: Math.round(convert(5) * 255),
green: Math.round(convert(3) * 255),
blue: Math.round(convert(1) * 255),
alpha: Math.round(a * 255),
};
} }
// COLOR UTILITY FUNCTIONS // COLOR UTILITY FUNCTIONS
export function isColor(value: unknown): value is Color { export function isSRgba8(value: unknown): value is SRGBA8 {
return typeof value === "object" && value !== null && "red" in value; return typeof value === "object" && value !== null && "red" in value;
} }
export function colorFromCSS(colorCode: string): Color | undefined { // Parse a CSS color string into an `SRGBA8`. Uses a canvas to delegate parsing to the browser.
export function sRgba8FromCSS(colorCode: string): SRGBA8 | undefined {
// Allow single-digit hex value inputs // Allow single-digit hex value inputs
let colorValue = colorCode.trim(); let colorValue = colorCode.trim();
if (colorValue.length === 2 && colorValue.charAt(0) === "#" && /[0-9a-f]/i.test(colorValue.charAt(1))) { if (colorValue.length === 2 && colorValue.charAt(0) === "#" && /[0-9a-f]/i.test(colorValue.charAt(1))) {
@@ -52,52 +59,40 @@ export function colorFromCSS(colorCode: string): Color | undefined {
// Invalid color // Invalid color
if (comparisonA !== comparisonB) { if (comparisonA !== comparisonB) {
// If this color code didn't start with a #, add it and try again // If this color code didn't start with a #, add it and try again
if (colorValue.trim().charAt(0) !== "#") return colorFromCSS(`#${colorValue.trim()}`); if (colorValue.trim().charAt(0) !== "#") return sRgba8FromCSS(`#${colorValue.trim()}`);
return undefined; return undefined;
} }
context.fillRect(0, 0, 1, 1); context.fillRect(0, 0, 1, 1);
const [r, g, b, a] = [...context.getImageData(0, 0, 1, 1).data]; const [r, g, b, a] = [...context.getImageData(0, 0, 1, 1).data];
return createColor(r / 255, g / 255, b / 255, a / 255); return createSRgba8(r, g, b, a);
} }
export function colorToHexNoAlpha(color: Color): string { export function sRgba8ToHexNoAlpha(color: SRGBA8): string {
const r = Math.round(color.red * 255) const r = color.red.toString(16).padStart(2, "0");
.toString(16) const g = color.green.toString(16).padStart(2, "0");
.padStart(2, "0"); const b = color.blue.toString(16).padStart(2, "0");
const g = Math.round(color.green * 255)
.toString(16)
.padStart(2, "0");
const b = Math.round(color.blue * 255)
.toString(16)
.padStart(2, "0");
return `#${r}${g}${b}`; return `#${r}${g}${b}`;
} }
export function colorToRgb255(color: Color): RGB { export function sRgba8ToRgb255(color: SRGBA8): RGB {
return { return { r: color.red, g: color.green, b: color.blue };
r: Math.round(color.red * 255),
g: Math.round(color.green * 255),
b: Math.round(color.blue * 255),
};
} }
export function colorToRgbCSS(color: Color): string { export function sRgba8ToRgbCSS(color: SRGBA8): string {
const rgb = colorToRgb255(color); return `rgb(${color.red}, ${color.green}, ${color.blue})`;
return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
} }
export function colorToRgbaCSS(color: Color): string { export function sRgba8ToRgbaCSS(color: SRGBA8): string {
const rgb = colorToRgb255(color); return `rgba(${color.red}, ${color.green}, ${color.blue}, ${color.alpha / 255})`;
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${color.alpha})`;
} }
export function colorToHSV(color: Color): HSV { export function sRgba8ToHSV(color: SRGBA8): HSV {
const { red: r, green: g, blue: b } = color; const r = color.red / 255;
const g = color.green / 255;
const b = color.blue / 255;
const max = Math.max(r, g, b); const max = Math.max(r, g, b);
const min = Math.min(r, g, b); const min = Math.min(r, g, b);
@@ -126,15 +121,17 @@ export function colorToHSV(color: Color): HSV {
return { h, s, v }; return { h, s, v };
} }
export function colorOpaque(color: Color): Color { export function sRgba8Opaque(color: SRGBA8): SRGBA8 {
return createColor(color.red, color.green, color.blue, 1); return createSRgba8(color.red, color.green, color.blue, 255);
} }
export function colorLuminance(color: Color): number { // WCAG-style relative luminance computed from an `SRGBA8` (alpha composited over white).
export function sRgba8Luminance(color: SRGBA8): number {
const a = color.alpha / 255;
// Convert alpha into white // Convert alpha into white
const r = color.red * color.alpha + (1 - color.alpha); const r = (color.red / 255) * a + (1 - a);
const g = color.green * color.alpha + (1 - color.alpha); const g = (color.green / 255) * a + (1 - a);
const b = color.blue * color.alpha + (1 - color.alpha); const b = (color.blue / 255) * a + (1 - a);
// https://stackoverflow.com/a/3943023/775283 // https://stackoverflow.com/a/3943023/775283
@@ -145,32 +142,32 @@ export function colorLuminance(color: Color): number {
return linearR * 0.2126 + linearG * 0.7152 + linearB * 0.0722; return linearR * 0.2126 + linearG * 0.7152 + linearB * 0.0722;
} }
export function colorContrastingColor(color: Color | undefined): "black" | "white" { export function sRgba8ContrastingColor(color: SRGBA8 | undefined): "black" | "white" {
if (!color) return "black"; if (!color) return "black";
const luminance = colorLuminance(color); const luminance = sRgba8Luminance(color);
return luminance > Math.sqrt(1.05 * 0.05) - 0.05 ? "black" : "white"; return luminance > Math.sqrt(1.05 * 0.05) - 0.05 ? "black" : "white";
} }
export function contrastingOutlineFactor(value: FillChoice, proximityColor: string | [string, string], proximityRange: number): number { export function contrastingOutlineFactor(value: FillChoiceUI, proximityColor: string | [string, string], proximityRange: number): number {
const pair = Array.isArray(proximityColor) ? [proximityColor[0], proximityColor[1]] : [proximityColor, proximityColor]; const pair = Array.isArray(proximityColor) ? [proximityColor[0], proximityColor[1]] : [proximityColor, proximityColor];
const [range1, range2] = pair.map((color) => colorFromCSS(window.getComputedStyle(document.body).getPropertyValue(color))); const [range1, range2] = pair.map((color) => sRgba8FromCSS(window.getComputedStyle(document.body).getPropertyValue(color)));
const contrast = (color: Color | undefined): number => { const contrast = (color: SRGBA8 | undefined): number => {
if (!color) return 0; if (!color) return 0;
const lum = colorLuminance(color); const lum = sRgba8Luminance(color);
let rangeLuminance1 = range1 ? colorLuminance(range1) : 0; let rangeLuminance1 = range1 ? sRgba8Luminance(range1) : 0;
let rangeLuminance2 = range2 ? colorLuminance(range2) : 0; let rangeLuminance2 = range2 ? sRgba8Luminance(range2) : 0;
[rangeLuminance1, rangeLuminance2] = [Math.min(rangeLuminance1, rangeLuminance2), Math.max(rangeLuminance1, rangeLuminance2)]; [rangeLuminance1, rangeLuminance2] = [Math.min(rangeLuminance1, rangeLuminance2), Math.max(rangeLuminance1, rangeLuminance2)];
const distance = Math.max(0, rangeLuminance1 - lum, lum - rangeLuminance2); const distance = Math.max(0, rangeLuminance1 - lum, lum - rangeLuminance2);
return (1 - Math.min(distance / proximityRange, 1)) * (1 - colorToHSV(color).s); return (1 - Math.min(distance / proximityRange, 1)) * (1 - sRgba8ToHSV(color).s);
}; };
const gradientStops = fillChoiceGradientStops(value); const gradientStops = fillChoiceUIGradientStops(value);
if (gradientStops) { if (gradientStops) {
if (gradientStops.color.length === 0) return 0; if (gradientStops.color.length === 0) return 0;
@@ -180,30 +177,30 @@ export function contrastingOutlineFactor(value: FillChoice, proximityColor: stri
return Math.min(first, last); return Math.min(first, last);
} }
return contrast(fillChoiceColor(value)); return contrast(fillChoiceUIColor(value));
} }
// GRADIENT UTILITY FUNCTIONS // GRADIENT UTILITY FUNCTIONS
export function isGradientStops(value: unknown): value is GradientStops { export function isGradientStopsUI(value: unknown): value is GradientStopsUI {
return typeof value === "object" && value !== null && "position" in value && "midpoint" in value && "color" in value; return typeof value === "object" && value !== null && "position" in value && "midpoint" in value && "color" in value;
} }
// FILL CHOICE UTILITY FUNCTIONS // FILL CHOICE UTILITY FUNCTIONS
export function fillChoiceColor(value: FillChoice): Color | undefined { export function fillChoiceUIColor(value: FillChoiceUI): SRGBA8 | undefined {
if (typeof value === "object" && "Solid" in value) return value.Solid; if (typeof value === "object" && "Solid" in value) return value.Solid;
return undefined; return undefined;
} }
export function fillChoiceGradientStops(value: FillChoice): GradientStops | undefined { export function fillChoiceUIGradientStops(value: FillChoiceUI): GradientStopsUI | undefined {
if (typeof value === "object" && "Gradient" in value) return value.Gradient; if (typeof value === "object" && "Gradient" in value) return value.Gradient;
return undefined; return undefined;
} }
export function parseFillChoice(value: unknown): FillChoice { export function parseFillChoiceUI(value: unknown): FillChoiceUI {
if (value === "None" || value === undefined || value === null) return "None"; if (value === "None" || value === undefined || value === null) return "None";
if (typeof value === "object" && value !== null && "Solid" in value && isColor(value.Solid)) return { Solid: value.Solid }; if (typeof value === "object" && value !== null && "Solid" in value && isSRgba8(value.Solid)) return { Solid: value.Solid };
if (typeof value === "object" && value !== null && "Gradient" in value && isGradientStops(value.Gradient)) return { Gradient: value.Gradient }; if (typeof value === "object" && value !== null && "Gradient" in value && isGradientStopsUI(value.Gradient)) return { Gradient: value.Gradient };
return "None"; return "None";
} }
+1
View File
@@ -25,6 +25,7 @@ editor = { path = "../../editor", package = "graphite-editor", features = ["gpu"
graphene-std = { workspace = true } graphene-std = { workspace = true }
# Workspace dependencies # Workspace dependencies
bytemuck = { workspace = true }
graph-craft = { workspace = true } graph-craft = { workspace = true }
log = { workspace = true } log = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
+43 -83
View File
@@ -16,14 +16,17 @@ use editor::consts::FILE_EXTENSION;
use editor::messages::clipboard::utility_types::ClipboardContentRaw; use editor::messages::clipboard::utility_types::ClipboardContentRaw;
use editor::messages::input_mapper::utility_types::input_keyboard::ModifierKeys; use editor::messages::input_mapper::utility_types::input_keyboard::ModifierKeys;
use editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, ScrollDelta}; use editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, ScrollDelta};
use editor::messages::layout::utility_types::layout_widget::LayoutTarget;
use editor::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use editor::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use editor::messages::portfolio::document::utility_types::network_interface::ImportOrExport; use editor::messages::portfolio::document::utility_types::network_interface::ImportOrExport;
use editor::messages::portfolio::utility_types::{DockingSplitDirection, FontCatalog, FontCatalogFamily, PanelGroupId, PanelType}; use editor::messages::portfolio::utility_types::{DockingSplitDirection, FontCatalog, FontCatalogFamily, PanelGroupId, PanelType};
use editor::messages::prelude::*; use editor::messages::prelude::*;
use editor::messages::tool::tool_messages::tool_prelude::WidgetId; use editor::messages::tool::tool_messages::tool_prelude::WidgetId;
use graph_craft::document::NodeId; use graph_craft::document::NodeId;
use graphene_std::color::SRGBA8;
use graphene_std::graphene_hash::CacheHashWrapper; use graphene_std::graphene_hash::CacheHashWrapper;
use graphene_std::raster::color::Color; use graphene_std::raster::color::Color;
use graphene_std::vector::style::{FillChoice, FillChoiceUI};
use serde::Serialize; use serde::Serialize;
use serde_wasm_bindgen::{self, from_value}; use serde_wasm_bindgen::{self, from_value};
use std::cell::RefCell; use std::cell::RefCell;
@@ -319,20 +322,20 @@ impl EditorWrapper {
/// Update the value of a given UI widget, but don't commit it to the history (unless `commit_layout()` is called, which handles that) /// Update the value of a given UI widget, but don't commit it to the history (unless `commit_layout()` is called, which handles that)
#[wasm_bindgen(js_name = widgetValueUpdate)] #[wasm_bindgen(js_name = widgetValueUpdate)]
pub fn widget_value_update(&self, layout_target: JsValue, widget_id: u64, value: JsValue, resend_widget: bool) -> Result<(), JsValue> { pub fn widget_value_update(&self, layout_target: LayoutTarget, widget_id: u64, value: JsValue, resend_widget: bool) -> Result<(), JsValue> {
self.widget_value_update_helper(layout_target, widget_id, value, resend_widget) self.widget_value_update_helper(layout_target, widget_id, value, resend_widget)
} }
/// Commit the value of a given UI widget to the history /// Commit the value of a given UI widget to the history
#[wasm_bindgen(js_name = widgetValueCommit)] #[wasm_bindgen(js_name = widgetValueCommit)]
pub fn widget_value_commit(&self, layout_target: JsValue, widget_id: u64, value: JsValue) -> Result<(), JsValue> { pub fn widget_value_commit(&self, layout_target: LayoutTarget, widget_id: u64, value: JsValue) -> Result<(), JsValue> {
self.widget_value_commit_helper(layout_target, widget_id, value) self.widget_value_commit_helper(layout_target, widget_id, value)
} }
/// Update the value of a given UI widget, and commit it to the history /// Update the value of a given UI widget, and commit it to the history
#[wasm_bindgen(js_name = widgetValueCommitAndUpdate)] #[wasm_bindgen(js_name = widgetValueCommitAndUpdate)]
pub fn widget_value_commit_and_update(&self, layout_target: JsValue, widget_id: u64, value: JsValue, resend_widget: bool) -> Result<(), JsValue> { pub fn widget_value_commit_and_update(&self, layout_target: LayoutTarget, widget_id: u64, value: JsValue, resend_widget: bool) -> Result<(), JsValue> {
self.widget_value_commit_helper(layout_target.clone(), widget_id, value.clone())?; self.widget_value_commit_helper(layout_target, widget_id, value.clone())?;
self.widget_value_update_helper(layout_target, widget_id, value, resend_widget)?; self.widget_value_update_helper(layout_target, widget_id, value, resend_widget)?;
// Close out a transaction that the widget's `on_commit` opened (if any), so a single click on widgets like the // Close out a transaction that the widget's `on_commit` opened (if any), so a single click on widgets like the
// NumberInput's increment buttons collapses into one history step instead of leaving the transaction in `Modified` // NumberInput's increment buttons collapses into one history step instead of leaving the transaction in `Modified`
@@ -346,34 +349,24 @@ impl EditorWrapper {
self.dispatch(DocumentMessage::EndTransaction); self.dispatch(DocumentMessage::EndTransaction);
} }
pub fn widget_value_update_helper(&self, layout_target: JsValue, widget_id: u64, value: JsValue, resend_widget: bool) -> Result<(), JsValue> { pub fn widget_value_update_helper(&self, layout_target: LayoutTarget, widget_id: u64, value: JsValue, resend_widget: bool) -> Result<(), JsValue> {
let widget_id = WidgetId(widget_id); let widget_id = WidgetId(widget_id);
match (from_value(layout_target), from_value(value)) { let value: serde_json::Value = from_value(value).map_err(|e| Error::new(&format!("Could not update UI: {e}")))?;
(Ok(layout_target), Ok(value)) => { let message = LayoutMessage::WidgetValueUpdate { layout_target, widget_id, value };
let message = LayoutMessage::WidgetValueUpdate { layout_target, widget_id, value }; self.dispatch(message);
self.dispatch(message); if resend_widget {
let resend_message = LayoutMessage::ResendActiveWidget { layout_target, widget_id };
if resend_widget { self.dispatch(resend_message);
let resend_message = LayoutMessage::ResendActiveWidget { layout_target, widget_id };
self.dispatch(resend_message);
}
Ok(())
}
(target, val) => Err(Error::new(&format!("Could not update UI\nDetails:\nTarget: {target:?}\nValue: {val:?}")).into()),
} }
Ok(())
} }
pub fn widget_value_commit_helper(&self, layout_target: JsValue, widget_id: u64, value: JsValue) -> Result<(), JsValue> { pub fn widget_value_commit_helper(&self, layout_target: LayoutTarget, widget_id: u64, value: JsValue) -> Result<(), JsValue> {
let widget_id = WidgetId(widget_id); let widget_id = WidgetId(widget_id);
match (from_value(layout_target), from_value(value)) { let value: serde_json::Value = from_value(value).map_err(|e| Error::new(&format!("Could not commit UI: {e}")))?;
(Ok(layout_target), Ok(value)) => { let message = LayoutMessage::WidgetValueCommit { layout_target, widget_id, value };
let message = LayoutMessage::WidgetValueCommit { layout_target, widget_id, value }; self.dispatch(message);
self.dispatch(message); Ok(())
Ok(())
}
(target, val) => Err(Error::new(&format!("Could not commit UI\nDetails:\nTarget: {target:?}\nValue: {val:?}")).into()),
}
} }
#[wasm_bindgen(js_name = loadPreferences)] #[wasm_bindgen(js_name = loadPreferences)]
@@ -389,14 +382,8 @@ impl EditorWrapper {
} }
#[wasm_bindgen(js_name = loadPersistedState)] #[wasm_bindgen(js_name = loadPersistedState)]
pub fn load_persisted_state(&self, state: JsValue) { pub fn load_persisted_state(&self, state: editor::messages::frontend::utility_types::PersistedState) {
let Ok(state) = serde_wasm_bindgen::from_value(state) else { self.dispatch(PersistentStateMessage::LoadState { state });
log::error!("Failed to deserialize persisted state");
return;
};
let message = PersistentStateMessage::LoadState { state };
self.dispatch(message);
} }
#[wasm_bindgen(js_name = loadDocumentContent)] #[wasm_bindgen(js_name = loadDocumentContent)]
@@ -494,9 +481,7 @@ impl EditorWrapper {
} }
#[wasm_bindgen(js_name = splitPanelGroup)] #[wasm_bindgen(js_name = splitPanelGroup)]
pub fn split_panel_group(&self, target_group: u64, direction: String, tabs: JsValue, active_tab_index: usize) { pub fn split_panel_group(&self, target_group: u64, direction: DockingSplitDirection, tabs: Vec<PanelType>, active_tab_index: usize) {
let direction: DockingSplitDirection = serde_wasm_bindgen::from_value(JsValue::from_str(&direction)).unwrap();
let tabs: Vec<PanelType> = serde_wasm_bindgen::from_value(tabs).unwrap();
let message = PortfolioMessage::SplitPanelGroup { let message = PortfolioMessage::SplitPanelGroup {
target_group: PanelGroupId(target_group), target_group: PanelGroupId(target_group),
direction, direction,
@@ -507,9 +492,8 @@ impl EditorWrapper {
} }
#[wasm_bindgen(js_name = setPanelGroupSizes)] #[wasm_bindgen(js_name = setPanelGroupSizes)]
pub fn set_panel_group_sizes(&self, split_path: JsValue, sizes: JsValue) { pub fn set_panel_group_sizes(&self, split_path: Vec<u32>, sizes: Vec<f64>) {
let split_path: Vec<usize> = serde_wasm_bindgen::from_value(split_path).unwrap(); let split_path = split_path.into_iter().map(|i| i as usize).collect();
let sizes: Vec<f64> = serde_wasm_bindgen::from_value(sizes).unwrap();
let message = PortfolioMessage::SetPanelGroupSizes { split_path, sizes }; let message = PortfolioMessage::SetPanelGroupSizes { split_path, sizes };
self.dispatch(message); self.dispatch(message);
} }
@@ -645,13 +629,8 @@ impl EditorWrapper {
/// The font catalog has been loaded /// The font catalog has been loaded
#[wasm_bindgen(js_name = onFontCatalogLoad)] #[wasm_bindgen(js_name = onFontCatalogLoad)]
pub fn on_font_catalog_load(&self, catalog: JsValue) -> Result<(), JsValue> { pub fn on_font_catalog_load(&self, catalog: Vec<FontCatalogFamily>) {
// Deserializing from TS type: `{ name: string; styles: { weight: number, italic: boolean, url: string }[] }[]` self.dispatch(PortfolioMessage::FontCatalogLoaded { catalog: FontCatalog(catalog) });
let families = serde_wasm_bindgen::from_value::<Vec<FontCatalogFamily>>(catalog)?;
let message = PortfolioMessage::FontCatalogLoaded { catalog: FontCatalog(families) };
self.dispatch(message);
Ok(())
} }
/// A font has been downloaded /// A font has been downloaded
@@ -679,44 +658,29 @@ impl EditorWrapper {
Ok(()) Ok(())
} }
/// Update primary color with values on a scale from 0 to 1. /// Update primary color from sRGB bytes (the wire format at the JS boundary).
#[wasm_bindgen(js_name = updatePrimaryColor)] #[wasm_bindgen(js_name = updatePrimaryColor)]
pub fn update_primary_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> { pub fn update_primary_color(&self, color: SRGBA8) {
let Some(primary_color) = Color::from_rgbaf32(red, green, blue, alpha) else { self.dispatch(ToolMessage::SelectWorkingColor {
return Err(Error::new("Invalid color").into()); color: Color::from(color),
};
let message = ToolMessage::SelectWorkingColor {
color: primary_color.to_linear_srgb(),
primary: true, primary: true,
}; });
self.dispatch(message);
Ok(())
} }
/// Update secondary color with values on a scale from 0 to 1. /// Update secondary color from sRGB bytes (the wire format at the JS boundary).
#[wasm_bindgen(js_name = updateSecondaryColor)] #[wasm_bindgen(js_name = updateSecondaryColor)]
pub fn update_secondary_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> { pub fn update_secondary_color(&self, color: SRGBA8) {
let Some(secondary_color) = Color::from_rgbaf32(red, green, blue, alpha) else { self.dispatch(ToolMessage::SelectWorkingColor {
return Err(Error::new("Invalid color").into()); color: Color::from(color),
};
let message = ToolMessage::SelectWorkingColor {
color: secondary_color.to_linear_srgb(),
primary: false, primary: false,
}; });
self.dispatch(message);
Ok(())
} }
/// Initialize the Rust color picker handler with a starting value (used when the frontend `<ColorPicker>` opens). /// Initialize the Rust color picker handler with a starting value (used when the frontend `<ColorPicker />` opens).
#[wasm_bindgen(js_name = openColorPicker)] #[wasm_bindgen(js_name = openColorPicker)]
pub fn open_color_picker(&self, initial_value: JsValue, allow_none: bool, disabled: bool) -> Result<(), JsValue> { pub fn open_color_picker(&self, initial_value: FillChoiceUI, allow_none: bool, disabled: bool) {
let initial_value = serde_wasm_bindgen::from_value(initial_value).map_err(|e| Error::new(&format!("Invalid initial picker value: {e}")))?; let initial_value = FillChoice::from(&initial_value);
self.dispatch(ColorPickerMessage::Open { initial_value, allow_none, disabled }); self.dispatch(ColorPickerMessage::Open { initial_value, allow_none, disabled });
Ok(())
} }
/// Tell the Rust color picker handler that the popover is closing. /// Tell the Rust color picker handler that the popover is closing.
@@ -725,14 +689,10 @@ impl EditorWrapper {
self.dispatch(ColorPickerMessage::Close); self.dispatch(ColorPickerMessage::Close);
} }
/// Update the color of the currently-edited gradient stop /// Update the color of the currently-edited gradient stop, from sRGB bytes (the wire format at the JS boundary).
#[wasm_bindgen(js_name = updateGradientStopColor)] #[wasm_bindgen(js_name = updateGradientStopColor)]
pub fn update_gradient_stop_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> { pub fn update_gradient_stop_color(&self, color: SRGBA8) {
let Some(color) = Color::from_rgbaf32(red, green, blue, alpha) else { self.dispatch(GradientToolMessage::UpdateStopColor { color: Color::from(color) });
return Err(Error::new("Invalid color").into());
};
self.dispatch(GradientToolMessage::UpdateStopColor { color: color.to_linear_srgb() });
Ok(())
} }
/// Start a new undo transaction for gradient stop color editing /// Start a new undo transaction for gradient stop color editing
+5 -4
View File
@@ -6,8 +6,8 @@ use crate::{EDITOR_HAS_CRASHED, EDITOR_WRAPPER};
use editor::application::Editor; use editor::application::Editor;
use editor::messages::input_mapper::utility_types::input_keyboard::Key; use editor::messages::input_mapper::utility_types::input_keyboard::Key;
use editor::messages::prelude::*; use editor::messages::prelude::*;
use graphene_std::color::SRGBA8;
use graphene_std::raster::Image; use graphene_std::raster::Image;
use graphene_std::raster::color::Color;
use js_sys::{Object, Reflect}; use js_sys::{Object, Reflect};
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use std::time::Duration; use std::time::Duration;
@@ -119,7 +119,7 @@ pub(crate) fn auto_save_all_documents() {
}); });
} }
pub(crate) fn render_image_data_to_canvases(image_data: &[(u64, Image<Color>)]) { pub(crate) fn render_image_data_to_canvases(image_data: &[(u64, Image<SRGBA8>)]) {
let window = match window() { let window = match window() {
Some(window) => window, Some(window) => window,
None => { None => {
@@ -167,8 +167,9 @@ pub(crate) fn render_image_data_to_canvases(image_data: &[(u64, Image<Color>)])
.expect("2d context was not found") .expect("2d context was not found")
.dyn_into::<CanvasRenderingContext2d>() .dyn_into::<CanvasRenderingContext2d>()
.expect("Failed to cast context to CanvasRenderingContext2d"); .expect("Failed to cast context to CanvasRenderingContext2d");
let u8_data: Vec<u8> = image.data.iter().flat_map(|color| color.to_rgba8_srgb()).collect(); // `SRGBA8` is `#[repr(C)]` of four `u8`s, so the data buffer is already the byte format the canvas expects
let clamped_u8_data = wasm_bindgen::Clamped(&u8_data[..]); let u8_data: &[u8] = bytemuck::cast_slice(&image.data);
let clamped_u8_data = wasm_bindgen::Clamped(u8_data);
match ImageData::new_with_u8_clamped_array_and_sh(clamped_u8_data, image.width, image.height) { match ImageData::new_with_u8_clamped_array_and_sh(clamped_u8_data, image.width, image.height) {
Ok(image_data_obj) => { Ok(image_data_obj) => {
if context.put_image_data(&image_data_obj, 0., 0.).is_err() { if context.put_image_data(&image_data_obj, 0., 0.).is_err() {
+2 -1
View File
@@ -2,6 +2,7 @@ use super::DocumentNode;
use crate::application_io::PlatformEditorApi; use crate::application_io::PlatformEditorApi;
use crate::proto::{Any as DAny, FutureAny}; use crate::proto::{Any as DAny, FutureAny};
use brush_nodes::brush_stroke::BrushStroke; use brush_nodes::brush_stroke::BrushStroke;
use core_types::color::SRGBA8;
use core_types::list::List; use core_types::list::List;
use core_types::transform::Footprint; use core_types::transform::Footprint;
use core_types::uuid::NodeId; use core_types::uuid::NodeId;
@@ -464,7 +465,7 @@ impl TaggedValue {
// String syntax (e.g. "000000ff") // String syntax (e.g. "000000ff")
if input.starts_with('"') && input.ends_with('"') { if input.starts_with('"') && input.ends_with('"') {
let hex = input.trim().trim_matches('"').trim().trim_start_matches('#'); let hex = input.trim().trim_matches('"').trim().trim_start_matches('#');
let color = Color::from_hex_str(hex); let color = SRGBA8::from_hex_str(hex).map(Color::from);
if color.is_none() { if color.is_none() {
log::error!("Invalid default value color string: {input}"); log::error!("Invalid default value color string: {input}");
} }
@@ -20,6 +20,7 @@ no-std-types = { workspace = true, features = ["std"] }
graphene-hash = { workspace = true, features = ["derive"] } graphene-hash = { workspace = true, features = ["derive"] }
# Workspace dependencies # Workspace dependencies
color = { workspace = true }
bitflags = { workspace = true } bitflags = { workspace = true }
bytemuck = { workspace = true } bytemuck = { workspace = true }
node-macro = { workspace = true } node-macro = { workspace = true }
@@ -102,3 +102,37 @@ pub fn migrate_to_f64_array<'de, D: serde::Deserializer<'de>>(deserializer: D) -
F64ArrayFormat::List(list) => list.element, F64ArrayFormat::List(list) => list.element,
}) })
} }
/// Parse a CSS color string (named color, hex, `rgb(...)`, `hsl(...)`, etc.) into a linear-light [`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.
pub fn parse_css_color(input: &str) -> Option<crate::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: color::AlphaColor<color::Srgb> = parsed.to_alpha_color();
let [red, green, blue, alpha] = srgb.components;
// Reject out-of-gamut values that `color::parse_color` accepts for newer CSS syntax (e.g., `rgb(300 -50 200)`).
let in_gamut = alpha <= 1. && ![red, green, blue, alpha].iter().any(|c| c.is_sign_negative() || !c.is_finite());
in_gamut.then(|| crate::Color::from_gamma_srgb_channels(red, green, blue, alpha))
}
@@ -92,17 +92,112 @@ impl Alpha for RGBA16F {
impl Pixel for RGBA16F {} impl Pixel for RGBA16F {}
/// An sRGB color with 8-bit unassociated-alpha channels. Used as the wire format at the DOM boundary:
/// bijective with hex codes, byte-identical to CSS/SVG/PNG/peniko conventions. Internal computations use
/// the linear-light [`Color`] type. Convert via [`From<SRGBA8> for Color`] and [`From<Color> for SRGBA8`].
#[repr(C)] #[repr(C)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
#[cfg_attr(feature = "std", derive(dyn_any::DynAny, serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "std", derive(dyn_any::DynAny, serde::Serialize, serde::Deserialize))]
#[derive(Debug, Default, Clone, Copy, PartialEq, Pod, Zeroable)] #[cfg_attr(feature = "std", derive(graphene_hash::CacheHash))]
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Pod, Zeroable)]
pub struct SRGBA8 { pub struct SRGBA8 {
red: u8, pub red: u8,
green: u8, pub green: u8,
blue: u8, pub blue: u8,
alpha: u8, pub alpha: u8,
} }
impl SRGBA8 {
pub const TRANSPARENT: Self = Self::new(0, 0, 0, 0);
pub const BLACK: Self = Self::new(0, 0, 0, 255);
pub const WHITE: Self = Self::new(255, 255, 255, 255);
/// Construct from raw 8-bit channels. Alpha is unassociated (not premultiplied), matching CSS/SVG/PNG convention.
#[inline(always)]
pub const fn new(red: u8, green: u8, blue: u8, alpha: u8) -> Self {
Self { red, green, blue, alpha }
}
/// Construct an opaque (alpha = 255) color from raw 8-bit RGB channels.
#[inline(always)]
pub const fn new_opaque(red: u8, green: u8, blue: u8) -> Self {
Self::new(red, green, blue, 255)
}
/// Parse `RRGGBB` or `RRGGBBAA` (with or without a leading `#`). Returns `None` for any other format.
/// For full CSS Color 4 parsing (named colors, shorthand hex, `rgb(...)`, `hsl(...)`), parse in the caller and construct via [`Self::new`].
#[cfg(feature = "std")]
pub fn from_hex_str(hex: &str) -> Option<Self> {
let hex = hex.trim().trim_start_matches('#');
if hex.len() != 6 && hex.len() != 8 {
return None;
}
let red = u8::from_str_radix(&hex[0..2], 16).ok()?;
let green = u8::from_str_radix(&hex[2..4], 16).ok()?;
let blue = u8::from_str_radix(&hex[4..6], 16).ok()?;
let alpha = if hex.len() == 8 { u8::from_str_radix(&hex[6..8], 16).ok()? } else { 255 };
Some(Self::new(red, green, blue, alpha))
}
/// `rrggbb` (lowercase, no `#` prefix, alpha discarded). Use where alpha is specified separately, e.g. SVG `fill="#..." fill-opacity="..."`.
#[cfg(feature = "std")]
pub fn to_rgb_hex(self) -> String {
format!("{:02x}{:02x}{:02x}", self.red, self.green, self.blue)
}
/// `rrggbbaa` (lowercase, no `#` prefix).
#[cfg(feature = "std")]
pub fn to_rgba_hex(self) -> String {
format!("{:02x}{:02x}{:02x}{:02x}", self.red, self.green, self.blue, self.alpha)
}
/// `#rrggbb` if fully opaque, `#rrggbbaa` otherwise. Suitable for direct insertion into a CSS property or SVG attribute.
#[cfg(feature = "std")]
pub fn to_css_hex(self) -> String {
if self.alpha == 255 {
format!("#{}", self.to_rgb_hex())
} else {
format!("#{}", self.to_rgba_hex())
}
}
/// Returns [`Self::BLACK`] or [`Self::WHITE`], whichever gives more legible text against this color
/// (alpha composited over white in gamma space, WCAG-style relative-luminance threshold).
pub fn contrasting_text_color(self) -> Self {
// Composite over white in gamma space, then convert to linear for the luminance test.
let r = self.red as f32 / 255.;
let g = self.green as f32 / 255.;
let b = self.blue as f32 / 255.;
let a = self.alpha as f32 / 255.;
let composited = Color::from_gamma_srgb_channels(1. - a + r * a, 1. - a + g * a, 1. - a + b * a, 1.);
let luminance = composited.luminance_rec_709();
// 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 }
}
}
impl From<[u8; 4]> for SRGBA8 {
#[inline(always)]
fn from(bytes: [u8; 4]) -> Self {
let [red, green, blue, alpha] = bytes;
Self::new(red, green, blue, alpha)
}
}
impl From<SRGBA8> for [u8; 4] {
#[inline(always)]
fn from(c: SRGBA8) -> Self {
let SRGBA8 { red, green, blue, alpha } = c;
[red, green, blue, alpha]
}
}
/// Lets `Image<SRGBA8>` cross the wasm boundary as gamma bytes, since `Color` (linear-light) isn't exposed with Tsify.
impl Pixel for SRGBA8 {}
impl From<Color> for SRGBA8 { impl From<Color> for SRGBA8 {
#[inline(always)] #[inline(always)]
fn from(c: Color) -> Self { fn from(c: Color) -> Self {
@@ -127,53 +222,6 @@ impl From<SRGBA8> for Color {
} }
} }
impl Luminance for SRGBA8 {
type LuminanceChannel = f32;
#[inline(always)]
fn luminance(&self) -> f32 {
// TODO: verify this is correct for sRGB
0.2126 * self.red() + 0.7152 * self.green() + 0.0722 * self.blue()
}
}
impl RGB for SRGBA8 {
type ColorChannel = f32;
#[inline(always)]
fn red(&self) -> f32 {
self.red as f32 / 255.
}
#[inline(always)]
fn green(&self) -> f32 {
self.green as f32 / 255.
}
#[inline(always)]
fn blue(&self) -> f32 {
self.blue as f32 / 255.
}
}
impl Rec709Primaries for SRGBA8 {}
impl SRGB for SRGBA8 {}
impl Alpha for SRGBA8 {
type AlphaChannel = f32;
#[inline(always)]
fn alpha(&self) -> f32 {
self.alpha as f32 / 255.
}
const TRANSPARENT: Self = SRGBA8 { red: 0, green: 0, blue: 0, alpha: 0 };
fn multiplied_alpha(&self, alpha: Self::AlphaChannel) -> Self {
let alpha = alpha * 255.;
let mut result = *self;
result.alpha = (alpha * self.alpha()) as u8;
result
}
}
impl Pixel for SRGBA8 {}
#[repr(C)] #[repr(C)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "std", derive(dyn_any::DynAny, serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "std", derive(dyn_any::DynAny, serde::Serialize, serde::Deserialize))]
@@ -216,9 +264,13 @@ impl Pixel for Luma {}
/// Internally alpha is stored as `f32` that ranges from `0.0` (transparent) to `1.0` (opaque). /// Internally alpha is stored as `f32` that ranges from `0.0` (transparent) to `1.0` (opaque).
/// The other components (RGB) are stored as `f32` that range from `0.0` up to `f32::MAX`, /// The other components (RGB) are stored as `f32` that range from `0.0` up to `f32::MAX`,
/// the values encode the brightness of each channel proportional to the light intensity in cd/m² (nits) in HDR, and `0.0` (black) to `1.0` (white) in SDR color. /// the values encode the brightness of each channel proportional to the light intensity in cd/m² (nits) in HDR, and `0.0` (black) to `1.0` (white) in SDR color.
/// Linear-light sRGB color with `f32` channels (alpha unassociated for swatch/UI colors, associated/premultiplied for pixel data inside [`Image<Color>`]).
///
/// Channels range from `0.0` to `f32::MAX`, encoding brightness proportional to light intensity (cd/m² nits in HDR, or `0..=1` mapped to white for SDR).
///
/// Anything crossing the Wasm/JS boundary must go through [`SRGBA8`] instead.
#[repr(C)] #[repr(C)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
#[cfg_attr(feature = "std", derive(dyn_any::DynAny, serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "std", derive(graphene_hash::CacheHash))] #[cfg_attr(feature = "std", derive(graphene_hash::CacheHash))]
#[derive(Debug, Default, Clone, Copy, PartialEq, Pod, Zeroable, BufferStruct)] #[derive(Debug, Default, Clone, Copy, PartialEq, Pod, Zeroable, BufferStruct)]
pub struct Color { pub struct Color {
@@ -231,6 +283,52 @@ pub struct Color {
// `f32` channels mean `Color` doesn't qualify for a derived `Eq`, but in practice we never store NaN here, and the renderer's `HashMap<CacheHashWrapper<Image<Color>>, _>` deduplication needs `Color: Eq` to propagate up through the wrapper. // `f32` channels mean `Color` doesn't qualify for a derived `Eq`, but in practice we never store NaN here, and the renderer's `HashMap<CacheHashWrapper<Image<Color>>, _>` deduplication needs `Color: Eq` to propagate up through the wrapper.
impl Eq for Color {} impl Eq for Color {}
// TODO: Eventually remove this migration document upgrade code
#[cfg(feature = "std")]
impl serde::Serialize for Color {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
// Persist linear-light floats directly and tag with `"linear": true` so legacy gamma-encoded values (which lack this marker) can be detected and upgraded on load.
let mut state = serializer.serialize_struct("Color", 5)?;
state.serialize_field("red", &self.red)?;
state.serialize_field("green", &self.green)?;
state.serialize_field("blue", &self.blue)?;
state.serialize_field("alpha", &self.alpha)?;
// TODO: Remove the `linear` marker when switching to the new document format and Ctrl-C node serialization format
state.serialize_field("linear", &true)?;
state.end()
}
}
// TODO: Eventually remove this migration document upgrade code
#[cfg(feature = "std")]
impl<'de> serde::Deserialize<'de> for Color {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
// Documents from before the linear-storage migration lack the `linear` marker and stored gamma-encoded floats; convert them on load.
#[derive(serde::Deserialize)]
struct MigrationColor {
red: f32,
green: f32,
blue: f32,
alpha: f32,
#[serde(default)]
// TODO: Remove the `linear` marker when switching to the new document format and Ctrl-C node serialization format
linear: bool,
}
let raw = MigrationColor::deserialize(deserializer)?;
Ok(if raw.linear {
Color {
red: raw.red,
green: raw.green,
blue: raw.blue,
alpha: raw.alpha,
}
} else {
Color::from_gamma_srgb_channels(raw.red, raw.green, raw.blue, raw.alpha)
})
}
}
impl RGB for Color { impl RGB for Color {
type ColorChannel = f32; type ColorChannel = f32;
#[inline(always)] #[inline(always)]
@@ -266,11 +364,14 @@ impl AlphaMut for Color {
impl Pixel for Color { impl Pixel for Color {
#[cfg(feature = "std")] #[cfg(feature = "std")]
fn to_bytes(&self) -> Vec<u8> { fn to_bytes(&self) -> Vec<u8> {
self.to_rgba8_srgb().to_vec() let SRGBA8 { red, green, blue, alpha } = (*self).into();
[red, green, blue, alpha].to_vec()
} }
fn from_bytes(bytes: &[u8]) -> Self { fn from_bytes(bytes: &[u8]) -> Self {
Color::from_rgba8_srgb(bytes[0], bytes[1], bytes[2], bytes[3]) // `Image<Color>` pixel convention is linear-light with associated (premultiplied) alpha.
let srgba = SRGBA8::new(bytes[0], bytes[1], bytes[2], bytes[3]);
Color::from(srgba).apply_opacity(bytes[3] as f32 / 255.)
} }
fn byte_size() -> usize { fn byte_size() -> usize {
4 4
@@ -372,56 +473,26 @@ impl Color {
Some(color) Some(color)
} }
/// Return an opaque `Color` from given `f32` RGB channels. /// Construct an opaque `Color` from `f32` RGB channels, with no value validation (use [`Self::from_rgbaf32`] for validation).
#[inline(always)] #[inline(always)]
pub const fn from_rgbf32_unchecked(red: f32, green: f32, blue: f32) -> Color { pub const fn from_rgbf32_unchecked(red: f32, green: f32, blue: f32) -> Color {
Color { red, green, blue, alpha: 1. } Color { red, green, blue, alpha: 1. }
} }
/// Return an opaque `Color` from given `f32` RGB channels. /// Construct a `Color` from `f32` RGBA channels, with no value validation (use [`Self::from_rgbaf32`] for validation).
#[inline(always)] #[inline(always)]
pub const fn from_rgbaf32_unchecked(red: f32, green: f32, blue: f32, alpha: f32) -> Color { pub const fn from_rgbaf32_unchecked(red: f32, green: f32, blue: f32, alpha: f32) -> Color {
Color { red, green, blue, alpha } Color { red, green, blue, alpha }
} }
/// Return an opaque `Color` from given `f32` RGB channels. /// Construct a `Color` from unassociated (straight) RGBA channels, premultiplying the RGB channels by alpha.
#[inline(always)] #[inline(always)]
pub fn from_unassociated_alpha(red: f32, green: f32, blue: f32, alpha: f32) -> Color { pub fn new_from_unassociated_rgba(red: f32, green: f32, blue: f32, alpha: f32) -> Color {
Color::from_rgbaf32_unchecked(red * alpha, green * alpha, blue * alpha, alpha) Color::from_rgbaf32_unchecked(red * alpha, green * alpha, blue * alpha, alpha)
} }
/// Return an opaque SDR `Color` given RGB channels from `0` to `255`, premultiplied by alpha. /// Create a linear-light `Color` from HSL coordinates (all between 0 and 1).
/// /// HSL is defined on sRGB display values, so the RGB produced by the HSL math is gamma-encoded and decoded to linear before being wrapped in `Color`.
/// # Examples
/// ```
/// use core_types::color::Color;
/// let color = Color::from_rgb8_srgb(0x72, 0x67, 0x62);
/// let color2 = Color::from_rgba8_srgb(0x72, 0x67, 0x62, 0xFF);
/// assert_eq!(color, color2)
/// ```
#[inline(always)]
pub fn from_rgb8_srgb(red: u8, green: u8, blue: u8) -> Color {
Color::from_rgba8_srgb(red, green, blue, 255)
}
// TODO: Should this be premult?
/// Return an SDR `Color` given RGBA channels from `0` to `255`, premultiplied by alpha.
///
/// # Examples
/// ```
/// use core_types::color::Color;
/// let color = Color::from_rgba8_srgb(0x72, 0x67, 0x62, 0x61);
/// ```
#[inline(always)]
pub fn from_rgba8_srgb(red: u8, green: u8, blue: u8, alpha: u8) -> Color {
let red = red as f32 / 255.;
let green = green as f32 / 255.;
let blue = blue as f32 / 255.;
let alpha = alpha as f32 / 255.;
Color { red, green, blue, alpha }.to_linear_srgb().map_rgb(|channel| channel * alpha)
}
/// Create a [Color] from a hue, saturation, lightness, and alpha (all between 0 and 1)
/// ///
/// # Examples /// # Examples
/// ``` /// ```
@@ -460,10 +531,11 @@ impl Color {
map_channel(&mut green, temp2, temp1); map_channel(&mut green, temp2, temp1);
map_channel(&mut blue, temp2, temp1); map_channel(&mut blue, temp2, temp1);
Color { red, green, blue, alpha } Color::from_gamma_srgb_channels(red, green, blue, alpha)
} }
/// Create a [Color] from hue, saturation, value, and alpha (all between 0 and 1). /// Create a linear-light `Color` from HSV coordinates (all between 0 and 1).
/// HSV is defined on sRGB display values, so the RGB produced by the HSV math is gamma-encoded and decoded to linear before being wrapped in `Color`.
pub fn from_hsva(hue: f32, saturation: f32, value: f32, alpha: f32) -> Color { pub fn from_hsva(hue: f32, saturation: f32, value: f32, alpha: f32) -> Color {
let h_prime = (hue * 6.) % 6.; let h_prime = (hue * 6.) % 6.;
let i = h_prime as i32; let i = h_prime as i32;
@@ -479,7 +551,7 @@ impl Color {
4 => (t, p, value), 4 => (t, p, value),
_ => (value, p, q), _ => (value, p, q),
}; };
Color { red, green, blue, alpha } Color::from_gamma_srgb_channels(red, green, blue, alpha)
} }
/// Return the `red` component. /// Return the `red` component.
@@ -534,48 +606,56 @@ impl Color {
self.alpha self.alpha
} }
/// Whether the alpha channel is at (or within an epsilon of) fully opaque.
#[inline(always)] #[inline(always)]
pub fn is_opaque(&self) -> bool { pub fn is_opaque(&self) -> bool {
self.alpha > 1. - f32::EPSILON self.alpha > 1. - f32::EPSILON
} }
/// Mean of the three RGB channels.
#[inline(always)] #[inline(always)]
pub fn average_rgb_channels(&self) -> f32 { pub fn average_rgb_channels(&self) -> f32 {
(self.red + self.green + self.blue) / 3. (self.red + self.green + self.blue) / 3.
} }
/// Minimum of the three RGB channels.
#[inline(always)] #[inline(always)]
pub fn minimum_rgb_channels(&self) -> f32 { pub fn minimum_rgb_channels(&self) -> f32 {
self.red.min(self.green).min(self.blue) self.red.min(self.green).min(self.blue)
} }
/// Maximum of the three RGB channels.
#[inline(always)] #[inline(always)]
pub fn maximum_rgb_channels(&self) -> f32 { pub fn maximum_rgb_channels(&self) -> f32 {
self.red.max(self.green).max(self.blue) self.red.max(self.green).max(self.blue)
} }
/// Relative luminance using Rec.709 / sRGB-primary weights, computed on linear-light RGB.
// From https://stackoverflow.com/a/56678483/775283 // From https://stackoverflow.com/a/56678483/775283
#[inline(always)] #[inline(always)]
pub fn luminance_srgb(&self) -> f32 { pub fn luminance_rec_709(&self) -> f32 {
0.2126 * self.red + 0.7152 * self.green + 0.0722 * self.blue 0.2126 * self.red + 0.7152 * self.green + 0.0722 * self.blue
} }
/// Luma using Rec.601 SDTV coefficients.
// From https://en.wikipedia.org/wiki/Luma_(video)#Rec._601_luma_versus_Rec._709_luma_coefficients // From https://en.wikipedia.org/wiki/Luma_(video)#Rec._601_luma_versus_Rec._709_luma_coefficients
#[inline(always)] #[inline(always)]
pub fn luminance_rec_601(&self) -> f32 { pub fn luminance_rec_601(&self) -> f32 {
0.299 * self.red + 0.587 * self.green + 0.114 * self.blue 0.299 * self.red + 0.587 * self.green + 0.114 * self.blue
} }
/// Luma using rounded Rec.601 coefficients (`0.3 / 0.59 / 0.11`), as used by some legacy image processing.
// From https://en.wikipedia.org/wiki/Luma_(video)#Rec._601_luma_versus_Rec._709_luma_coefficients // From https://en.wikipedia.org/wiki/Luma_(video)#Rec._601_luma_versus_Rec._709_luma_coefficients
#[inline(always)] #[inline(always)]
pub fn luminance_rec_601_rounded(&self) -> f32 { pub fn luminance_rec_601_rounded(&self) -> f32 {
0.3 * self.red + 0.59 * self.green + 0.11 * self.blue 0.3 * self.red + 0.59 * self.green + 0.11 * self.blue
} }
/// Perceptual lightness (CIE L*) of the Rec.709 luminance, normalized to 0..1.
// From https://stackoverflow.com/a/56678483/775283 // From https://stackoverflow.com/a/56678483/775283
#[inline(always)] #[inline(always)]
pub fn luminance_perceptual(&self) -> f32 { pub fn luminance_perceptual(&self) -> f32 {
let luminance = self.luminance_srgb(); let luminance = self.luminance_rec_709();
if luminance <= 0.008856 { if luminance <= 0.008856 {
(luminance * 903.3) / 100. (luminance * 903.3) / 100.
@@ -584,6 +664,7 @@ impl Color {
} }
} }
/// Construct an opaque grayscale color where R = G = B = `luminance`.
#[inline(always)] #[inline(always)]
pub fn from_luminance(luminance: f32) -> Color { pub fn from_luminance(luminance: f32) -> Color {
Color { Color {
@@ -594,26 +675,30 @@ impl Color {
} }
} }
/// Shift all RGB channels by the offset that moves Rec.601-rounded luma to `luminance`, clamping channels to 0..1. Approximate; channels above 1 are lost.
#[inline(always)] #[inline(always)]
pub fn with_luminance(&self, luminance: f32) -> Color { pub fn with_luminance(&self, luminance: f32) -> Color {
let delta = luminance - self.luminance_rec_601_rounded(); let delta = luminance - self.luminance_rec_601_rounded();
self.map_rgb(|c| (c + delta).clamp(0., 1.)) self.map_rgb(|c| (c + delta).clamp(0., 1.))
} }
/// The RGB chroma range, `max - min` across the three channels. Not the HSL/HSV saturation (use [`Self::to_hsla`] or [`Self::to_hsva`] for those).
#[inline(always)] #[inline(always)]
pub fn saturation(&self) -> f32 { pub fn chroma_range(&self) -> f32 {
let max = (self.red).max(self.green).max(self.blue); let max = (self.red).max(self.green).max(self.blue);
let min = (self.red).min(self.green).min(self.blue); let min = (self.red).min(self.green).min(self.blue);
max - min max - min
} }
/// Replace HSL saturation with the given value, preserving hue, lightness, and alpha.
#[inline(always)] #[inline(always)]
pub fn with_saturation(&self, saturation: f32) -> Color { pub fn with_saturation(&self, saturation: f32) -> Color {
let [hue, _, lightness, alpha] = self.to_hsla(); let [hue, _, lightness, alpha] = self.to_hsla();
Color::from_hsla(hue, saturation, lightness, alpha) Color::from_hsla(hue, saturation, lightness, alpha)
} }
/// Replace the alpha channel, leaving RGB unchanged.
pub fn with_alpha(&self, alpha: f32) -> Color { pub fn with_alpha(&self, alpha: f32) -> Color {
Color { Color {
red: self.red, red: self.red,
@@ -623,6 +708,7 @@ impl Color {
} }
} }
/// Replace the red channel, leaving the others unchanged.
pub fn with_red(&self, red: f32) -> Color { pub fn with_red(&self, red: f32) -> Color {
Color { Color {
red, red,
@@ -632,6 +718,7 @@ impl Color {
} }
} }
/// Replace the green channel, leaving the others unchanged.
pub fn with_green(&self, green: f32) -> Color { pub fn with_green(&self, green: f32) -> Color {
Color { Color {
red: self.red, red: self.red,
@@ -641,6 +728,7 @@ impl Color {
} }
} }
/// Replace the blue channel, leaving the others unchanged.
pub fn with_blue(&self, blue: f32) -> Color { pub fn with_blue(&self, blue: f32) -> Color {
Color { Color {
red: self.red, red: self.red,
@@ -650,21 +738,25 @@ impl Color {
} }
} }
/// Per-channel "Normal" blend: returns the source channel unchanged.
#[inline(always)] #[inline(always)]
pub fn blend_normal(_c_b: f32, c_s: f32) -> f32 { pub fn blend_normal(_c_b: f32, c_s: f32) -> f32 {
c_s c_s
} }
/// Per-channel "Multiply" blend.
#[inline(always)] #[inline(always)]
pub fn blend_multiply(c_b: f32, c_s: f32) -> f32 { pub fn blend_multiply(c_b: f32, c_s: f32) -> f32 {
c_s * c_b c_s * c_b
} }
/// Per-channel "Darken" blend: the smaller of the two.
#[inline(always)] #[inline(always)]
pub fn blend_darken(c_b: f32, c_s: f32) -> f32 { pub fn blend_darken(c_b: f32, c_s: f32) -> f32 {
c_s.min(c_b) c_s.min(c_b)
} }
/// Per-channel "Color Burn" blend.
#[inline(always)] #[inline(always)]
pub fn blend_color_burn(c_b: f32, c_s: f32) -> f32 { pub fn blend_color_burn(c_b: f32, c_s: f32) -> f32 {
if c_b == 1. { if c_b == 1. {
@@ -676,41 +768,49 @@ impl Color {
} }
} }
/// Per-channel "Linear Burn" blend.
#[inline(always)] #[inline(always)]
pub fn blend_linear_burn(c_b: f32, c_s: f32) -> f32 { pub fn blend_linear_burn(c_b: f32, c_s: f32) -> f32 {
c_b + c_s - 1. c_b + c_s - 1.
} }
/// Whole-color "Darker Color" blend: keeps whichever color has the lower mean RGB.
#[inline(always)] #[inline(always)]
pub fn blend_darker_color(&self, other: Color) -> Color { pub fn blend_darker_color(&self, other: Color) -> Color {
if self.average_rgb_channels() <= other.average_rgb_channels() { *self } else { other } if self.average_rgb_channels() <= other.average_rgb_channels() { *self } else { other }
} }
/// Per-channel "Screen" blend.
#[inline(always)] #[inline(always)]
pub fn blend_screen(c_b: f32, c_s: f32) -> f32 { pub fn blend_screen(c_b: f32, c_s: f32) -> f32 {
1. - (1. - c_s) * (1. - c_b) 1. - (1. - c_s) * (1. - c_b)
} }
/// Per-channel "Lighten" blend: the larger of the two.
#[inline(always)] #[inline(always)]
pub fn blend_lighten(c_b: f32, c_s: f32) -> f32 { pub fn blend_lighten(c_b: f32, c_s: f32) -> f32 {
c_s.max(c_b) c_s.max(c_b)
} }
/// Per-channel "Color Dodge" blend.
#[inline(always)] #[inline(always)]
pub fn blend_color_dodge(c_b: f32, c_s: f32) -> f32 { pub fn blend_color_dodge(c_b: f32, c_s: f32) -> f32 {
if c_s == 1. { 1. } else { (c_b / (1. - c_s)).min(1.) } if c_s == 1. { 1. } else { (c_b / (1. - c_s)).min(1.) }
} }
/// Per-channel "Linear Dodge" (Add) blend.
#[inline(always)] #[inline(always)]
pub fn blend_linear_dodge(c_b: f32, c_s: f32) -> f32 { pub fn blend_linear_dodge(c_b: f32, c_s: f32) -> f32 {
c_b + c_s c_b + c_s
} }
/// Whole-color "Lighter Color" blend: keeps whichever color has the higher mean RGB.
#[inline(always)] #[inline(always)]
pub fn blend_lighter_color(&self, other: Color) -> Color { pub fn blend_lighter_color(&self, other: Color) -> Color {
if self.average_rgb_channels() >= other.average_rgb_channels() { *self } else { other } if self.average_rgb_channels() >= other.average_rgb_channels() { *self } else { other }
} }
/// Per-channel "Soft Light" blend.
pub fn blend_softlight(c_b: f32, c_s: f32) -> f32 { pub fn blend_softlight(c_b: f32, c_s: f32) -> f32 {
if c_s <= 0.5 { if c_s <= 0.5 {
c_b - (1. - 2. * c_s) * c_b * (1. - c_b) c_b - (1. - 2. * c_s) * c_b * (1. - c_b)
@@ -720,6 +820,7 @@ impl Color {
} }
} }
/// Per-channel "Hard Light" blend.
pub fn blend_hardlight(c_b: f32, c_s: f32) -> f32 { pub fn blend_hardlight(c_b: f32, c_s: f32) -> f32 {
if c_s <= 0.5 { if c_s <= 0.5 {
Color::blend_multiply(2. * c_s, c_b) Color::blend_multiply(2. * c_s, c_b)
@@ -728,6 +829,7 @@ impl Color {
} }
} }
/// Per-channel "Vivid Light" blend.
pub fn blend_vivid_light(c_b: f32, c_s: f32) -> f32 { pub fn blend_vivid_light(c_b: f32, c_s: f32) -> f32 {
if c_s <= 0.5 { if c_s <= 0.5 {
Color::blend_color_burn(2. * c_s, c_b) Color::blend_color_burn(2. * c_s, c_b)
@@ -736,6 +838,7 @@ impl Color {
} }
} }
/// Per-channel "Linear Light" blend.
pub fn blend_linear_light(c_b: f32, c_s: f32) -> f32 { pub fn blend_linear_light(c_b: f32, c_s: f32) -> f32 {
if c_s <= 0.5 { if c_s <= 0.5 {
Color::blend_linear_burn(2. * c_s, c_b) Color::blend_linear_burn(2. * c_s, c_b)
@@ -744,6 +847,7 @@ impl Color {
} }
} }
/// Per-channel "Pin Light" blend.
pub fn blend_pin_light(c_b: f32, c_s: f32) -> f32 { pub fn blend_pin_light(c_b: f32, c_s: f32) -> f32 {
if c_s <= 0.5 { if c_s <= 0.5 {
Color::blend_darken(2. * c_s, c_b) Color::blend_darken(2. * c_s, c_b)
@@ -752,45 +856,54 @@ impl Color {
} }
} }
/// Per-channel "Hard Mix" blend: thresholds Linear Light at 0.5.
pub fn blend_hard_mix(c_b: f32, c_s: f32) -> f32 { pub fn blend_hard_mix(c_b: f32, c_s: f32) -> f32 {
if Color::blend_linear_light(c_b, c_s) < 0.5 { 0. } else { 1. } if Color::blend_linear_light(c_b, c_s) < 0.5 { 0. } else { 1. }
} }
/// Per-channel "Difference" blend.
pub fn blend_difference(c_b: f32, c_s: f32) -> f32 { pub fn blend_difference(c_b: f32, c_s: f32) -> f32 {
(c_b - c_s).abs() (c_b - c_s).abs()
} }
/// Per-channel "Exclusion" blend.
pub fn blend_exclusion(c_b: f32, c_s: f32) -> f32 { pub fn blend_exclusion(c_b: f32, c_s: f32) -> f32 {
c_b + c_s - 2. * c_b * c_s c_b + c_s - 2. * c_b * c_s
} }
/// Per-channel "Subtract" blend.
pub fn blend_subtract(c_b: f32, c_s: f32) -> f32 { pub fn blend_subtract(c_b: f32, c_s: f32) -> f32 {
c_b - c_s c_b - c_s
} }
/// Per-channel "Divide" blend.
pub fn blend_divide(c_b: f32, c_s: f32) -> f32 { pub fn blend_divide(c_b: f32, c_s: f32) -> f32 {
if c_b == 0. { 1. } else { c_b / c_s } if c_b == 0. { 1. } else { c_b / c_s }
} }
/// Whole-color "Hue" blend: source hue with this color's saturation and Rec.601 luma.
pub fn blend_hue(&self, c_s: Color) -> Color { pub fn blend_hue(&self, c_s: Color) -> Color {
let sat_b = self.saturation(); let sat_b = self.chroma_range();
let lum_b = self.luminance_rec_601(); let lum_b = self.luminance_rec_601();
c_s.with_saturation(sat_b).with_luminance(lum_b) c_s.with_saturation(sat_b).with_luminance(lum_b)
} }
/// Whole-color "Saturation" blend: this color's hue/luma with source saturation.
pub fn blend_saturation(&self, c_s: Color) -> Color { pub fn blend_saturation(&self, c_s: Color) -> Color {
let sat_s = c_s.saturation(); let sat_s = c_s.chroma_range();
let lum_b = self.luminance_rec_601(); let lum_b = self.luminance_rec_601();
self.with_saturation(sat_s).with_luminance(lum_b) self.with_saturation(sat_s).with_luminance(lum_b)
} }
/// Whole-color "Color" blend: source hue/saturation with this color's luma.
pub fn blend_color(&self, c_s: Color) -> Color { pub fn blend_color(&self, c_s: Color) -> Color {
let lum_b = self.luminance_rec_601(); let lum_b = self.luminance_rec_601();
c_s.with_luminance(lum_b) c_s.with_luminance(lum_b)
} }
/// Whole-color "Luminosity" blend: this color's hue/saturation with source luma.
pub fn blend_luminosity(&self, c_s: Color) -> Color { pub fn blend_luminosity(&self, c_s: Color) -> Color {
let lum_s = c_s.luminance_rec_601(); let lum_s = c_s.luminance_rec_601();
@@ -810,98 +923,43 @@ impl Color {
(self.red, self.green, self.blue, self.alpha) (self.red, self.green, self.blue, self.alpha)
} }
/// Return an 8-character RGBA hex string (without a # prefix). Use this if the [`Color`] is in linear space. /// Convert this color to HSV coordinates (all between 0 and 1).
/// /// HSV is defined on sRGB display values, so this color's linear RGB is gamma-encoded before the HSV math.
/// # Examples pub fn to_hsva(&self) -> [f32; 4] {
/// ``` #[cfg(feature = "std")]
/// use core_types::color::Color; let rem = |x: f32, m: f32| x.rem_euclid(m);
/// let color = Color::from_rgba8_srgb(0x52, 0x67, 0xFA, 0x61); // Premultiplied alpha #[cfg(not(feature = "std"))]
/// assert_eq!("3240a261", color.to_rgba_hex_srgb()); // Equivalent hex incorporating premultiplied alpha let rem = |x: f32, m: f32| x.rem_euclid(&m);
/// ```
#[cfg(feature = "std")]
pub fn to_rgba_hex_srgb(&self) -> String {
let gamma = self.to_gamma_srgb();
format!(
"{:02x?}{:02x?}{:02x?}{:02x?}",
(gamma.r() * 255.) as u8,
(gamma.g() * 255.) as u8,
(gamma.b() * 255.) as u8,
(gamma.a() * 255.) as u8,
)
}
/// Return a 6-character RGB hex string (without a # prefix). Use this if the [`Color`] is in linear space. let [red, green, blue, alpha] = self.to_gamma_srgb_channels();
/// ``` let max = red.max(green).max(blue);
/// use core_types::color::Color; let min = red.min(green).min(blue);
/// let color = Color::from_rgba8_srgb(0x52, 0x67, 0xFA, 0x61); // Premultiplied alpha let delta = max - min;
/// assert_eq!("3240a2", color.to_rgb_hex_srgb()); // Equivalent hex incorporating premultiplied alpha
/// ```
#[cfg(feature = "std")]
pub fn to_rgb_hex_srgb(&self) -> String {
self.to_gamma_srgb().to_rgb_hex_srgb_from_gamma()
}
/// Return a 6-character RGB hex string (without a # prefix). Use this if the [`Color`] is in gamma space. let mut hue = if delta == 0. {
/// ``` 0.
/// use core_types::color::Color; } else if max == red {
/// let color = Color::from_rgba8_srgb(0x52, 0x67, 0xFA, 0x61); // Premultiplied alpha rem((green - blue) / delta, 6.)
/// assert_eq!("3240a2", color.to_rgb_hex_srgb()); // Equivalent hex incorporating premultiplied alpha } else if max == green {
/// ``` (blue - red) / delta + 2.
#[cfg(feature = "std")] } else {
pub fn to_rgb_hex_srgb_from_gamma(&self) -> String { (red - green) / delta + 4.
format!("{:02x?}{:02x?}{:02x?}", (self.r() * 255.) as u8, (self.g() * 255.) as u8, (self.b() * 255.) as u8) };
} hue = rem(hue * 60. + 360., 360.) / 360.;
/// Return an 8-character RGBA hex string (without a # prefix). Use this if the [`Color`] is in gamma space. let saturation = if max == 0. { 0. } else { delta / max };
#[cfg(feature = "std")] let value = max;
pub fn to_rgba_hex_srgb_from_gamma(&self) -> String {
format!(
"{:02x?}{:02x?}{:02x?}{:02x?}",
(self.r() * 255.) as u8,
(self.g() * 255.) as u8,
(self.b() * 255.) as u8,
(self.a() * 255.) as u8,
)
}
/// [`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. [hue, saturation, value, alpha]
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] {
[(self.red * 255.) as u8, (self.green * 255.) as u8, (self.blue * 255.) as u8, (self.alpha * 255.) as u8]
}
/// 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 linear space.
#[inline(always)]
pub fn to_rgba8_srgb(&self) -> [u8; 4] {
self.to_gamma_srgb().to_rgba8()
}
/// Return the all RGB components as a u8 slice, first component is red, followed by green, followed by blue. Use this if the [`Color`] is in gamma space.
#[inline(always)]
pub fn to_rgb8(&self) -> [u8; 3] {
[(self.red * 255.) as u8, (self.green * 255.) as u8, (self.blue * 255.) as u8]
}
/// Return the all RGB components as a u8 slice, first component is red, followed by green, followed by blue. Use this if the [`Color`] is in linear space.
#[inline(always)]
pub fn to_rgb8_srgb(&self) -> [u8; 3] {
self.to_gamma_srgb().to_rgb8()
} }
// https://www.niwa.nu/2013/05/math-behind-colorspace-conversions-rgb-hsl/ // https://www.niwa.nu/2013/05/math-behind-colorspace-conversions-rgb-hsl/
/// Convert a [Color] to a hue, saturation, lightness and alpha (all between 0 and 1) /// Convert this color to HSL coordinates (all between 0 and 1).
/// HSL is defined on sRGB display values, so this color's linear RGB is gamma-encoded before the HSL math.
pub fn to_hsla(&self) -> [f32; 4] { pub fn to_hsla(&self) -> [f32; 4] {
let min_channel = self.red.min(self.green).min(self.blue); let [red, green, blue, alpha] = self.to_gamma_srgb_channels();
let max_channel = self.red.max(self.green).max(self.blue); let min_channel = red.min(green).min(blue);
let max_channel = red.max(green).max(blue);
let lightness = (min_channel + max_channel) / 2.; let lightness = (min_channel + max_channel) / 2.;
let saturation = if min_channel == max_channel { let saturation = if min_channel == max_channel {
@@ -911,39 +969,22 @@ impl Color {
} else { } else {
(max_channel - min_channel) / (2. - max_channel - min_channel) (max_channel - min_channel) / (2. - max_channel - min_channel)
}; };
let hue = if self.red >= self.green && self.red >= self.blue { let hue = if red >= green && red >= blue {
(self.green - self.blue) / (max_channel - min_channel) (green - blue) / (max_channel - min_channel)
} else if self.green >= self.red && self.green >= self.blue { } else if green >= red && green >= blue {
2. + (self.blue - self.red) / (max_channel - min_channel) 2. + (blue - red) / (max_channel - min_channel)
} else { } else {
4. + (self.red - self.green) / (max_channel - min_channel) 4. + (red - green) / (max_channel - min_channel)
} / 6.; } / 6.;
#[cfg(feature = "std")] #[cfg(feature = "std")]
let hue = hue.rem_euclid(1.); let hue = hue.rem_euclid(1.);
#[cfg(not(feature = "std"))] #[cfg(not(feature = "std"))]
let hue = hue.rem_euclid(&1.); let hue = hue.rem_euclid(&1.);
[hue, saturation, lightness, self.alpha] [hue, saturation, lightness, alpha]
} }
/// Creates a color from a hex color code string with an optional `#` prefix, such as `#RRGGBB`, `RRGGBB`, `#RRGGBBAA`, or `RRGGBBAA`. /// Linearly interpolate each RGBA channel between `self` (`t = 0`) and `other` (`t = 1`); `t` must be in 0..=1.
/// Returns `None` for invalid or unrecognized strings.
#[cfg(feature = "std")]
pub fn from_hex_str(hex: &str) -> Option<Color> {
let hex = hex.trim().trim_start_matches('#');
if hex.len() != 6 && hex.len() != 8 {
return None;
}
let red = u8::from_str_radix(&hex[0..2], 16).ok()? as f32 / 255.;
let green = u8::from_str_radix(&hex[2..4], 16).ok()? as f32 / 255.;
let blue = u8::from_str_radix(&hex[4..6], 16).ok()? as f32 / 255.;
let alpha = if hex.len() == 8 { u8::from_str_radix(&hex[6..8], 16).ok()? as f32 / 255. } else { 1. };
Some(Color { red, green, blue, alpha })
}
/// Linearly interpolates between two colors based on t.
///
/// T must be between 0 and 1.
#[inline(always)] #[inline(always)]
pub fn lerp(&self, other: &Color, t: f32) -> Self { pub fn lerp(&self, other: &Color, t: f32) -> Self {
assert!((0. ..=1.).contains(&t)); assert!((0. ..=1.).contains(&t));
@@ -955,70 +996,62 @@ impl Color {
) )
} }
/// Generic power curve `c.powf(1 / exponent)` applied per RGB channel. Distinct from the sRGB transfer curve (see [`Self::to_gamma_srgb_channels`]).
/// The expected output must still be treated as linear-light.
#[inline(always)] #[inline(always)]
pub fn gamma(&self, gamma: f32) -> Color { pub fn apply_gamma_exponent(&self, exponent: f32) -> Color {
let gamma = gamma.max(0.0001); let exponent = exponent.max(0.0001);
// From https://www.dfstudios.co.uk/articles/programming/image-programming-algorithms/image-processing-algorithms-part-6-gamma-correction/ // From https://www.dfstudios.co.uk/articles/programming/image-programming-algorithms/image-processing-algorithms-part-6-gamma-correction/
let inverse_gamma = 1. / gamma; let inverse = 1. / exponent;
self.map_rgb(|c: f32| c.powf(inverse_gamma)) self.map_rgb(|c: f32| c.powf(inverse))
} }
/// Decompose into the four channel components after sRGB gamma encoding (linear → gamma). Alpha is unchanged.
/// Use [`Self::from_gamma_srgb_channels`] to wrap these gamma-encoded channels back into a linear-light `Color`.
#[inline(always)] #[inline(always)]
pub fn to_linear_srgb(&self) -> Self { pub fn to_gamma_srgb_channels(&self) -> [f32; 4] {
Self { [super::linear_to_srgb(self.red), super::linear_to_srgb(self.green), super::linear_to_srgb(self.blue), self.alpha]
red: Self::srgb_to_linear(self.red), }
green: Self::srgb_to_linear(self.green),
blue: Self::srgb_to_linear(self.blue), /// Construct a `Color` from sRGB gamma-encoded channel components, decoding RGB to linear-light. Alpha is unchanged.
alpha: self.alpha, #[inline(always)]
pub fn from_gamma_srgb_channels(red: f32, green: f32, blue: f32, alpha: f32) -> Color {
Color {
red: super::srgb_to_linear(red),
green: super::srgb_to_linear(green),
blue: super::srgb_to_linear(blue),
alpha,
} }
} }
/// Apply `f` to each RGB channel after sRGB gamma encoding, returning a linear-light `Color`. Alpha is unchanged.
/// Equivalent to unpacking via [`Self::to_gamma_srgb_channels`], mapping per channel, and rewrapping via [`Self::from_gamma_srgb_channels`].
#[inline(always)] #[inline(always)]
pub fn to_gamma_srgb(&self) -> Self { pub fn map_gamma_rgb<F: Fn(f32) -> f32>(&self, f: F) -> Color {
Self { let [r, g, b, a] = self.to_gamma_srgb_channels();
red: Self::linear_to_srgb(self.red), Color::from_gamma_srgb_channels(f(r), f(g), f(b), a)
green: Self::linear_to_srgb(self.green),
blue: Self::linear_to_srgb(self.blue),
alpha: self.alpha,
}
}
#[inline(always)]
pub fn srgb_to_linear(channel: f32) -> f32 {
if channel <= 0.04045 { channel / 12.92 } else { ((channel + 0.055) / 1.055).powf(2.4) }
}
#[inline(always)]
pub fn linear_to_srgb(channel: f32) -> f32 {
if channel <= 0.0031308 { channel * 12.92 } else { 1.055 * channel.powf(1. / 2.4) - 0.055 }
} }
/// Apply `f` to each of the four RGBA channels independently.
#[inline(always)] #[inline(always)]
pub fn map_rgba<F: Fn(f32) -> f32>(&self, f: F) -> Self { pub fn map_rgba<F: Fn(f32) -> f32>(&self, f: F) -> Self {
Self::from_rgbaf32_unchecked(f(self.r()), f(self.g()), f(self.b()), f(self.a())) Self::from_rgbaf32_unchecked(f(self.r()), f(self.g()), f(self.b()), f(self.a()))
} }
/// Apply `f` to each of the three RGB channels; alpha is unchanged.
#[inline(always)] #[inline(always)]
pub fn map_rgb<F: Fn(f32) -> f32>(&self, f: F) -> Self { pub fn map_rgb<F: Fn(f32) -> f32>(&self, f: F) -> Self {
Self::from_rgbaf32_unchecked(f(self.r()), f(self.g()), f(self.b()), self.a()) Self::from_rgbaf32_unchecked(f(self.r()), f(self.g()), f(self.b()), self.a())
} }
/// Multiply all four channels (including alpha) by `opacity`, applying an additional premultiplication factor to this Color.
#[inline(always)] #[inline(always)]
pub fn apply_opacity(&self, opacity: f32) -> Self { pub fn apply_opacity(&self, opacity: f32) -> Self {
Self::from_rgbaf32_unchecked(self.r() * opacity, self.g() * opacity, self.b() * opacity, self.a() * opacity) Self::from_rgbaf32_unchecked(self.r() * opacity, self.g() * opacity, self.b() * opacity, self.a() * opacity)
} }
#[inline(always)] /// Divide RGB by alpha to recover unassociated (straight-alpha) channels; no-op if alpha is zero.
pub fn to_associated_alpha(&self, alpha: f32) -> Self {
Self {
red: self.red * alpha,
green: self.green * alpha,
blue: self.blue * alpha,
alpha: self.alpha * alpha,
}
}
#[inline(always)] #[inline(always)]
pub fn to_unassociated_alpha(&self) -> Self { pub fn to_unassociated_alpha(&self) -> Self {
if self.alpha == 0. { if self.alpha == 0. {
@@ -1033,6 +1066,7 @@ impl Color {
} }
} }
/// Apply a per-channel blend function to this color (unmultiplied) and `other`, returning a color with `other`'s alpha; channels are clamped to 0..1.
#[inline(always)] #[inline(always)]
pub fn blend_rgb<F: Fn(f32, f32) -> f32>(&self, other: Color, f: F) -> Self { pub fn blend_rgb<F: Fn(f32, f32) -> f32>(&self, other: Color, f: F) -> Self {
let background = self.to_unassociated_alpha(); let background = self.to_unassociated_alpha();
@@ -1044,6 +1078,7 @@ impl Color {
} }
} }
/// Porter-Duff "source over" composite of `other` over `self`. Both colors must use associated (premultiplied) alpha.
#[inline(always)] #[inline(always)]
pub fn alpha_blend(&self, other: Color) -> Self { pub fn alpha_blend(&self, other: Color) -> Self {
let inv_alpha = 1. - other.alpha; let inv_alpha = 1. - other.alpha;
@@ -1055,6 +1090,7 @@ impl Color {
} }
} }
/// Replace alpha with `self.alpha + other.alpha`, clamped to 0..1; RGB is unchanged.
#[inline(always)] #[inline(always)]
pub fn alpha_add(&self, other: Color) -> Self { pub fn alpha_add(&self, other: Color) -> Self {
Self { Self {
@@ -1063,6 +1099,7 @@ impl Color {
} }
} }
/// Replace alpha with `self.alpha - other.alpha`, clamped to 0..1; RGB is unchanged.
#[inline(always)] #[inline(always)]
pub fn alpha_subtract(&self, other: Color) -> Self { pub fn alpha_subtract(&self, other: Color) -> Self {
Self { Self {
@@ -1071,6 +1108,7 @@ impl Color {
} }
} }
/// Replace alpha with `self.alpha * other.alpha`, clamped to 0..1; RGB is unchanged.
#[inline(always)] #[inline(always)]
pub fn alpha_multiply(&self, other: Color) -> Self { pub fn alpha_multiply(&self, other: Color) -> Self {
Self { Self {
@@ -1079,6 +1117,7 @@ impl Color {
} }
} }
/// Construct from a `glam::Vec4` where `(x, y, z, w)` map to `(red, green, blue, alpha)`.
#[inline(always)] #[inline(always)]
pub const fn from_vec4(vec: Vec4) -> Self { pub const fn from_vec4(vec: Vec4) -> Self {
Self { Self {
@@ -1089,6 +1128,7 @@ impl Color {
} }
} }
/// Pack into a `glam::Vec4` as `(red, green, blue, alpha)`.
#[inline(always)] #[inline(always)]
pub fn to_vec4(&self) -> Vec4 { pub fn to_vec4(&self) -> Vec4 {
Vec4::new(self.red, self.green, self.blue, self.alpha) Vec4::new(self.red, self.green, self.blue, self.alpha)
@@ -1123,7 +1163,7 @@ mod tests {
(82, 84, 84), (82, 84, 84),
(255, 255, 178), (255, 255, 178),
] { ] {
let col = Color::from_rgb8_srgb(red, green, blue); let col: Color = SRGBA8::new(red, green, blue, 255).into();
let [hue, saturation, lightness, alpha] = col.to_hsla(); let [hue, saturation, lightness, alpha] = col.to_hsla();
let result = Color::from_hsla(hue, saturation, lightness, alpha); let result = Color::from_hsla(hue, saturation, lightness, alpha);
assert!((col.r() - result.r()) < f32::EPSILON * 100.); assert!((col.r() - result.r()) < f32::EPSILON * 100.);
@@ -1,7 +1,9 @@
mod color_traits; mod color_traits;
mod color_types; mod color_types;
mod discrete_srgb; mod discrete_srgb;
mod transfer;
pub use color_traits::*; pub use color_traits::*;
pub use color_types::*; pub use color_types::*;
pub use discrete_srgb::*; pub use discrete_srgb::*;
pub use transfer::*;
@@ -0,0 +1,19 @@
//! Analytic per-channel sRGB transfer functions (gamma encoding/decoding).
//!
//! These work in `f32` at full precision. For round-trip-exact `u8` ⇄ `f32` conversion at the
//! display byte boundary, use the lookup tables in [`super::discrete_srgb`] instead.
#[cfg(not(feature = "std"))]
use num_traits::float::Float;
/// Decode an sRGB gamma-encoded channel value to linear-light.
#[inline(always)]
pub fn srgb_to_linear(channel: f32) -> f32 {
if channel <= 0.04045 { channel / 12.92 } else { ((channel + 0.055) / 1.055).powf(2.4) }
}
/// Encode a linear-light channel value to sRGB gamma-encoded.
#[inline(always)]
pub fn linear_to_srgb(channel: f32) -> f32 {
if channel <= 0.0031308 { channel * 12.92 } else { 1.055 * channel.powf(1. / 2.4) - 0.055 }
}
+8 -25
View File
@@ -144,7 +144,14 @@ impl<P: Pixel> Image<P> {
impl Image<Color> { impl Image<Color> {
/// Generate Image from some frontend image data (the canvas pixels as u8s in a flat array) /// Generate Image from some frontend image data (the canvas pixels as u8s in a flat array)
pub fn from_image_data(image_data: &[u8], width: u32, height: u32) -> Self { pub fn from_image_data(image_data: &[u8], width: u32, height: u32) -> Self {
let data = image_data.chunks_exact(4).map(|v| Color::from_rgba8_srgb(v[0], v[1], v[2], v[3])).collect(); let data = image_data
.chunks_exact(4)
.map(|v| {
// `Image<Color>` pixels are stored linear-light with premultiplied alpha
let srgba = SRGBA8::new(v[0], v[1], v[2], v[3]);
Color::from(srgba).apply_opacity(v[3] as f32 / 255.)
})
.collect();
Image { Image {
width, width,
height, height,
@@ -263,30 +270,6 @@ impl<P: Pixel> AsRef<Image<P>> for Image<P> {
} }
} }
impl From<Image<Color>> for Image<SRGBA8> {
fn from(image: Image<Color>) -> Self {
let data = image.data.into_iter().map(|x| x.into()).collect();
Self {
data,
width: image.width,
height: image.height,
base64_string: None,
}
}
}
impl From<Image<SRGBA8>> for Image<Color> {
fn from(image: Image<SRGBA8>) -> Self {
let data = image.data.into_iter().map(|x| x.into()).collect();
Self {
data,
width: image.width,
height: image.height,
base64_string: None,
}
}
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
#[test] #[test]
@@ -1,4 +1,5 @@
use crate::renderer::{RenderParams, format_transform_matrix}; use crate::renderer::{RenderParams, format_transform_matrix};
use core_types::color::SRGBA8;
use core_types::uuid::generate_uuid; use core_types::uuid::generate_uuid;
use glam::DAffine2; use glam::DAffine2;
use graphic_types::vector_types::gradient::{Gradient, GradientType}; use graphic_types::vector_types::gradient::{Gradient, GradientType};
@@ -22,7 +23,7 @@ impl RenderExt for Gradient {
if position != 0. { if position != 0. {
let _ = write!(stop, r#" offset="{}""#, (position * 1_000_000.).round() / 1_000_000.); let _ = write!(stop, r#" offset="{}""#, (position * 1_000_000.).round() / 1_000_000.);
} }
let _ = write!(stop, r##" stop-color="#{}""##, color.to_rgb_hex_srgb_from_gamma()); let _ = write!(stop, r##" stop-color="#{}""##, SRGBA8::from(color).to_rgb_hex());
if color.a() < 1. { if color.a() < 1. {
let _ = write!(stop, r#" stop-opacity="{}""#, (color.a() * 1000.).round() / 1000.); let _ = write!(stop, r#" stop-opacity="{}""#, (color.a() * 1000.).round() / 1000.);
} }
@@ -86,7 +87,7 @@ impl RenderExt for Fill {
match self { match self {
Self::None => r#" fill="none""#.to_string(), Self::None => r#" fill="none""#.to_string(),
Self::Solid(color) => { Self::Solid(color) => {
let mut result = format!(r##" fill="#{}""##, color.to_rgb_hex_srgb_from_gamma()); let mut result = format!(r##" fill="#{}""##, SRGBA8::from(*color).to_rgb_hex());
if color.a() < 1. { if color.a() < 1. {
let _ = write!(result, r#" fill-opacity="{}""#, (color.a() * 1000.).round() / 1000.); let _ = write!(result, r#" fill-opacity="{}""#, (color.a() * 1000.).round() / 1000.);
} }
@@ -132,7 +133,7 @@ impl RenderExt for Stroke {
let paint_order = (self.paint_order != PaintOrder::StrokeAbove || render_params.override_paint_order).then_some(PaintOrder::StrokeBelow); let paint_order = (self.paint_order != PaintOrder::StrokeAbove || render_params.override_paint_order).then_some(PaintOrder::StrokeBelow);
// Render the needed stroke attributes // Render the needed stroke attributes
let mut attributes = format!(r##" stroke="#{}""##, color.to_rgb_hex_srgb_from_gamma()); let mut attributes = format!(r##" stroke="#{}""##, SRGBA8::from(color).to_rgb_hex());
if color.a() < 1. { if color.a() < 1. {
let _ = write!(&mut attributes, r#" stroke-opacity="{}""#, (color.a() * 1000.).round() / 1000.); let _ = write!(&mut attributes, r#" stroke-opacity="{}""#, (color.a() * 1000.).round() / 1000.);
} }
+20 -18
View File
@@ -1,10 +1,11 @@
use crate::render_ext::RenderExt; use crate::render_ext::RenderExt;
use crate::to_peniko::BlendModeExt; use crate::to_peniko::{BlendModeExt, ToPenikoColor};
use core_types::CacheHash; use core_types::CacheHash;
use core_types::blending::BlendMode; use core_types::blending::BlendMode;
use core_types::bounds::BoundingBox; use core_types::bounds::BoundingBox;
use core_types::bounds::RenderBoundingBox; use core_types::bounds::RenderBoundingBox;
use core_types::color::Color; use core_types::color::Color;
use core_types::color::SRGBA8;
use core_types::list::{Item, List}; use core_types::list::{Item, List};
use core_types::math::quad::Quad; use core_types::math::quad::Quad;
use core_types::render_complexity::RenderComplexity; use core_types::render_complexity::RenderComplexity;
@@ -273,19 +274,20 @@ pub fn black_or_white_for_best_contrast(background: Option<Color>) -> Color {
let alpha = bg.a(); let alpha = bg.a();
// Un-premultiply, then convert to gamma sRGB // Un-premultiply, then encode to gamma sRGB to do the composite in display space.
let srgb = if alpha > f32::EPSILON { let (gamma_r, gamma_g, gamma_b) = if alpha > f32::EPSILON {
Color::from_rgbaf32_unchecked(bg.r() / alpha, bg.g() / alpha, bg.b() / alpha, alpha).to_gamma_srgb() let [r, g, b, _] = Color::from_rgbaf32_unchecked(bg.r() / alpha, bg.g() / alpha, bg.b() / alpha, alpha).to_gamma_srgb_channels();
(r, g, b)
} else { } else {
Color::TRANSPARENT (0., 0., 0.)
}; };
// Composite over black in sRGB space, then convert back to linear for luminance // Composite over black in sRGB space (premultiplied by alpha), then decode to linear for the luminance test.
let composited = Color::from_rgbaf32_unchecked(srgb.r() * alpha, srgb.g() * alpha, srgb.b() * alpha, 1.).to_linear_srgb(); let composited = Color::from_gamma_srgb_channels(gamma_r * alpha, gamma_g * alpha, gamma_b * alpha, 1.);
let threshold = (1.05 * 0.05f32).sqrt() - 0.05; let threshold = (1.05 * 0.05f32).sqrt() - 0.05;
if composited.luminance_srgb() > threshold { Color::BLACK } else { Color::WHITE } if composited.luminance_rec_709() > threshold { Color::BLACK } else { Color::WHITE }
} }
pub fn to_transform(transform: DAffine2) -> usvg::Transform { pub fn to_transform(transform: DAffine2) -> usvg::Transform {
@@ -311,7 +313,7 @@ fn get_outline_styles(render_params: &RenderParams) -> (kurbo::Stroke, peniko::C
}; };
let outline_color = black_or_white_for_best_contrast(render_params.artboard_background); let outline_color = black_or_white_for_best_contrast(render_params.artboard_background);
let outline_color_peniko = peniko::Color::new([outline_color.r(), outline_color.g(), outline_color.b(), outline_color.a()]); let outline_color_peniko = SRGBA8::from(outline_color).to_peniko_color();
(outline_stroke, outline_color_peniko) (outline_stroke, outline_color_peniko)
} }
@@ -573,7 +575,7 @@ impl Render for List<Artboard> {
// Background // Background
render.leaf_tag("rect", |attributes| { render.leaf_tag("rect", |attributes| {
attributes.push("fill", format!("#{}", background.to_rgb_hex_srgb_from_gamma())); attributes.push("fill", format!("#{}", SRGBA8::from(background).to_rgb_hex()));
if background.a() < 1. { if background.a() < 1. {
attributes.push("fill-opacity", ((background.a() * 1000.).round() / 1000.).to_string()); attributes.push("fill-opacity", ((background.a() * 1000.).round() / 1000.).to_string());
} }
@@ -629,7 +631,7 @@ impl Render for List<Artboard> {
let artboard_transform = kurbo::Affine::new(transform.to_cols_array()); let artboard_transform = kurbo::Affine::new(transform.to_cols_array());
let color = peniko::Color::new([background.r(), background.g(), background.b(), background.a()]); let color = SRGBA8::from(background).to_peniko_color();
scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., artboard_transform, &rect); scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., artboard_transform, &rect);
scene.fill(peniko::Fill::NonZero, artboard_transform, color, None, &rect); scene.fill(peniko::Fill::NonZero, artboard_transform, color, None, &rect);
scene.pop_layer(); scene.pop_layer();
@@ -1182,7 +1184,7 @@ impl Render for List<Vector> {
// Closures to avoid duplicated fill/stroke drawing logic // Closures to avoid duplicated fill/stroke drawing logic
let do_fill_path = |scene: &mut Scene, path: &kurbo::BezPath, fill_rule: peniko::Fill| match element.style.fill() { let do_fill_path = |scene: &mut Scene, path: &kurbo::BezPath, fill_rule: peniko::Fill| match element.style.fill() {
Fill::Solid(color) => { Fill::Solid(color) => {
let fill = peniko::Brush::Solid(peniko::Color::new([color.r(), color.g(), color.b(), color.a()])); let fill = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color());
scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, None, path); scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, None, path);
} }
Fill::Gradient(gradient) => { Fill::Gradient(gradient) => {
@@ -1190,7 +1192,7 @@ impl Render for List<Vector> {
for (position, color, _) in gradient.stops.interpolated_samples() { for (position, color, _) in gradient.stops.interpolated_samples() {
stops.push(peniko::ColorStop { stops.push(peniko::ColorStop {
offset: position as f32, offset: position as f32,
color: peniko::color::DynamicColor::from_alpha_color(peniko::Color::new([color.r(), color.g(), color.b(), color.a()])), color: peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(color).to_peniko_color()),
}); });
} }
@@ -1267,7 +1269,7 @@ impl Render for List<Vector> {
let do_stroke = |scene: &mut Scene, width_scale: f64| { let do_stroke = |scene: &mut Scene, width_scale: f64| {
if let Some(stroke) = element.style.stroke() { if let Some(stroke) = element.style.stroke() {
let color = match stroke.color { let color = match stroke.color {
Some(color) => peniko::Color::new([color.r(), color.g(), color.b(), color.a()]), Some(color) => SRGBA8::from(color).to_peniko_color(),
None => peniko::Color::TRANSPARENT, None => peniko::Color::TRANSPARENT,
}; };
let cap = match stroke.cap { let cap = match stroke.cap {
@@ -1811,7 +1813,7 @@ impl Render for List<Color> {
const MAX: f64 = 1e7; const MAX: f64 = 1e7;
attributes.push("points", format!("{MAX},{MAX} -{MAX},{MAX} -{MAX},-{MAX} {MAX},-{MAX}")); attributes.push("points", format!("{MAX},{MAX} -{MAX},{MAX} -{MAX},-{MAX} {MAX},-{MAX}"));
attributes.push("fill", format!("#{}", color.to_rgb_hex_srgb_from_gamma())); attributes.push("fill", format!("#{}", SRGBA8::from(*color).to_rgb_hex()));
if color.a() < 1. { if color.a() < 1. {
attributes.push("fill-opacity", ((color.a() * 1000.).round() / 1000.).to_string()); attributes.push("fill-opacity", ((color.a() * 1000.).round() / 1000.).to_string());
} }
@@ -1838,7 +1840,7 @@ impl Render for List<Color> {
let blend_mode = blend_mode_attr.to_peniko(); let blend_mode = blend_mode_attr.to_peniko();
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
let vello_color = peniko::Color::new([color.r(), color.g(), color.b(), color.a()]); let vello_color = SRGBA8::from(*color).to_peniko_color();
let rect = kurbo::Rect::from_origin_size(kurbo::Point::ZERO, kurbo::Size::new(1., 1.)); let rect = kurbo::Rect::from_origin_size(kurbo::Point::ZERO, kurbo::Size::new(1., 1.));
@@ -1895,7 +1897,7 @@ impl Render for List<GradientStops> {
let mut stop_string = String::new(); let mut stop_string = String::new();
for (position, color, original_midpoint) in gradient.interpolated_samples() { for (position, color, original_midpoint) in gradient.interpolated_samples() {
let _ = write!(stop_string, r##"<stop offset="{}" stop-color="#{}""##, position, color.to_rgb_hex_srgb_from_gamma()); let _ = write!(stop_string, r##"<stop offset="{}" stop-color="#{}""##, position, SRGBA8::from(color).to_rgb_hex());
if color.a() < 1. { if color.a() < 1. {
let _ = write!(stop_string, r#" stop-opacity="{}""#, color.a()); let _ = write!(stop_string, r#" stop-opacity="{}""#, color.a());
} }
@@ -1977,7 +1979,7 @@ impl Render for List<GradientStops> {
for (position, color, _) in gradient.interpolated_samples() { for (position, color, _) in gradient.interpolated_samples() {
stops.push(peniko::ColorStop { stops.push(peniko::ColorStop {
offset: position as f32, offset: position as f32,
color: peniko::color::DynamicColor::from_alpha_color(peniko::Color::new([color.r(), color.g(), color.b(), color.a()])), color: peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(color).to_peniko_color()),
}) })
} }
@@ -1,10 +1,22 @@
use core_types::BlendMode; use core_types::BlendMode;
use core_types::color::SRGBA8;
use vello::peniko; use vello::peniko;
pub trait BlendModeExt { pub trait BlendModeExt {
fn to_peniko(&self) -> peniko::Mix; fn to_peniko(&self) -> peniko::Mix;
} }
pub trait ToPenikoColor {
fn to_peniko_color(&self) -> peniko::Color;
}
impl ToPenikoColor for SRGBA8 {
#[inline(always)]
fn to_peniko_color(&self) -> peniko::Color {
peniko::Color::from_rgba8(self.red, self.green, self.blue, self.alpha)
}
}
impl BlendModeExt for BlendMode { impl BlendModeExt for BlendMode {
fn to_peniko(&self) -> peniko::Mix { fn to_peniko(&self) -> peniko::Mix {
match self { match self {
@@ -1,4 +1,6 @@
use core_types::{Color, render_complexity::RenderComplexity}; use core_types::Color;
use core_types::color::SRGBA8;
use core_types::render_complexity::RenderComplexity;
use dyn_any::DynAny; use dyn_any::DynAny;
use glam::{DAffine2, DVec2}; use glam::{DAffine2, DVec2};
@@ -13,9 +15,9 @@ pub enum GradientType {
} }
// TODO: Someday we could switch this to a Box[T] to avoid over-allocation // TODO: Someday we could switch this to a Box[T] to avoid over-allocation
// TODO: Use linear not gamma colors /// A list of colors (linear, unassociated alpha) associated with positions (in the range 0 to 1) along a gradient.
/// A list of colors associated with positions (in the range 0 to 1) along a gradient. ///
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] /// Not exposed via Tsify; use [`GradientStopsUI`] at the JS boundary.
#[derive(Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)] #[derive(Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))] #[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct GradientStops { pub struct GradientStops {
@@ -27,6 +29,59 @@ pub struct GradientStops {
pub color: Vec<Color>, pub color: Vec<Color>,
} }
/// JS-boundary version of [`GradientStops`] where stop colors are [`SRGBA8`] byte triples instead of linear-light [`Color`].
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
#[derive(Debug, Clone, PartialEq, Default, DynAny)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct GradientStopsUI {
pub position: Vec<f64>,
pub midpoint: Vec<f64>,
pub color: Vec<SRGBA8>,
}
impl From<&GradientStops> for GradientStopsUI {
fn from(s: &GradientStops) -> Self {
Self {
position: s.position.clone(),
midpoint: s.midpoint.clone(),
color: s.color.iter().map(|c| SRGBA8::from(*c)).collect(),
}
}
}
impl From<&GradientStopsUI> for GradientStops {
fn from(s: &GradientStopsUI) -> Self {
Self {
position: s.position.clone(),
midpoint: s.midpoint.clone(),
color: s.color.iter().map(|c| Color::from(*c)).collect(),
}
}
}
impl GradientStopsUI {
/// CSS `linear-gradient(...)` string. Stops are emitted as `#rrggbbaa` hex (already gamma-encoded bytes).
pub fn to_css_linear_gradient(&self) -> String {
if self.position.len() <= 1 {
let hex = self.color.first().map(|c| c.to_rgba_hex()).unwrap_or_else(|| "000000ff".to_string());
return format!("linear-gradient(to right, #{hex} 0%, #{hex} 100%)");
}
// Sample via the midpoint-aware subdivision used for SVG/Vello stops so browser interpolation matches
let stops: GradientStops = self.into();
let pieces = stops
.interpolated_samples()
.into_iter()
.map(|(position, color, _)| {
let percent = ((position * 100.) * 1e2).round() / 1e2;
let hex = SRGBA8::from(color).to_rgba_hex();
format!("#{hex} {percent}%")
})
.collect::<Vec<_>>()
.join(", ");
format!("linear-gradient(to right, {pieces})")
}
}
// TODO: Eventually remove this migration document upgrade code // TODO: Eventually remove this migration document upgrade code
impl<'de> serde::Deserialize<'de> for GradientStops { impl<'de> serde::Deserialize<'de> for GradientStops {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
@@ -294,7 +349,7 @@ 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. /// 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 { pub fn to_css_linear_gradient(&self) -> String {
if self.position.len() <= 1 { 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()); let hex = self.color.first().map(|c| SRGBA8::from(*c).to_rgba_hex()).unwrap_or_else(|| "000000ff".to_string());
return format!("linear-gradient(to right, #{hex} 0%, #{hex} 100%)"); return format!("linear-gradient(to right, #{hex} 0%, #{hex} 100%)");
} }
let pieces = self let pieces = self
@@ -302,7 +357,7 @@ impl GradientStops {
.into_iter() .into_iter()
.map(|(position, color, _)| { .map(|(position, color, _)| {
let percent = ((position * 100.) * 1e2).round() / 1e2; let percent = ((position * 100.) * 1e2).round() / 1e2;
format!("#{} {percent}%", color.to_rgba_hex_srgb_from_gamma()) format!("#{} {percent}%", SRGBA8::from(color).to_rgba_hex())
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
@@ -313,13 +368,17 @@ impl GradientStops {
/// ///
/// Each sample is `(position, color, original_midpoint)` where `original_midpoint` is `Some(f64)` with the corresponding /// Each sample is `(position, color, original_midpoint)` where `original_midpoint` is `Some(f64)` with the corresponding
/// midpoint for actual gradient stops, and `None` for interpolated samples added to approximate midpoint curves. /// midpoint for actual gradient stops, and `None` for interpolated samples added to approximate midpoint curves.
///
/// Interpolation is performed in sRGB gamma space (then lifted back to linear-light for output) because the downstream SVG/CSS
/// renderer interpolates between adjacent `<stop>` colors in gamma space; doing the subdivision math in the same space ensures
/// the chosen samples actually match the curve the browser will draw.
pub fn interpolated_samples(&self) -> Vec<(f64, Color, Option<f64>)> { pub fn interpolated_samples(&self) -> Vec<(f64, Color, Option<f64>)> {
/// Controls accuracy vs. number of samples tradeoff. /// Controls accuracy vs. number of samples tradeoff.
/// 2/255 means the linear approximation will deviate by no more than 2 gradations of 8-bit color from the theoretically perfect curve with this midpoint bias. /// 2/255 means the linear approximation will deviate by no more than 2 gradations of 8-bit color from the theoretically perfect curve with this midpoint bias.
const THRESHOLD: f64 = 2. / 255.; const THRESHOLD: f64 = 2. / 255.;
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn subdivide(left: f64, right: f64, midpoint: f64, pos_a: f64, pos_b: f64, color_a: Color, color_b: Color, result: &mut Vec<(f64, Color, Option<f64>)>, depth: u32) { fn subdivide(left: f64, right: f64, midpoint: f64, pos_a: f64, pos_b: f64, color_a_gamma: [f32; 4], color_b_gamma: [f32; 4], result: &mut Vec<(f64, Color, Option<f64>)>, depth: u32) {
const MAX_DEPTH: u32 = 20; const MAX_DEPTH: u32 = 20;
if depth >= MAX_DEPTH { if depth >= MAX_DEPTH {
return; return;
@@ -333,13 +392,18 @@ impl GradientStops {
let y_linear = (y_left + y_right) / 2.; let y_linear = (y_left + y_right) / 2.;
if (y_actual - y_linear).abs() > THRESHOLD { if (y_actual - y_linear).abs() > THRESHOLD {
subdivide(left, mid, midpoint, pos_a, pos_b, color_a, color_b, result, depth + 1); subdivide(left, mid, midpoint, pos_a, pos_b, color_a_gamma, color_b_gamma, result, depth + 1);
let global_pos = pos_a + mid * (pos_b - pos_a); let global_pos = pos_a + mid * (pos_b - pos_a);
let color = color_a.lerp(&color_b, y_actual as f32); let t = y_actual as f32;
let r = color_a_gamma[0] + (color_b_gamma[0] - color_a_gamma[0]) * t;
let g = color_a_gamma[1] + (color_b_gamma[1] - color_a_gamma[1]) * t;
let b = color_a_gamma[2] + (color_b_gamma[2] - color_a_gamma[2]) * t;
let a = color_a_gamma[3] + (color_b_gamma[3] - color_a_gamma[3]) * t;
let color = Color::from_gamma_srgb_channels(r, g, b, a);
result.push((global_pos, color, None)); result.push((global_pos, color, None));
subdivide(mid, right, midpoint, pos_a, pos_b, color_a, color_b, result, depth + 1); subdivide(mid, right, midpoint, pos_a, pos_b, color_a_gamma, color_b_gamma, result, depth + 1);
} }
} }
@@ -368,7 +432,7 @@ impl GradientStops {
// Only subdivide if midpoint deviates from linear (0.5) // Only subdivide if midpoint deviates from linear (0.5)
if (midpoint - 0.5).abs() >= 1e-6 { if (midpoint - 0.5).abs() >= 1e-6 {
subdivide(0., 1., midpoint, pos_a, pos_b, color_a, color_b, &mut result, 0); subdivide(0., 1., midpoint, pos_a, pos_b, color_a.to_gamma_srgb_channels(), color_b.to_gamma_srgb_channels(), &mut result, 0);
} }
// Add the end stop // Add the end stop
@@ -440,7 +504,7 @@ impl std::fmt::Display for Gradient {
let stops = self let stops = self
.stops .stops
.iter() .iter()
.map(|stop| format!("[{}%: #{}]", round(stop.position * 100.), stop.color.to_rgba_hex_srgb())) .map(|stop| format!("[{}%: #{}]", round(stop.position * 100.), SRGBA8::from(stop.color).to_rgba_hex()))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
write!(f, "{} Gradient: {stops}", self.gradient_type) write!(f, "{} Gradient: {stops}", self.gradient_type)
@@ -454,12 +518,12 @@ impl Gradient {
GradientStop { GradientStop {
position: 0., position: 0.,
midpoint: 0.5, midpoint: 0.5,
color: start_color.to_gamma_srgb(), color: start_color,
}, },
GradientStop { GradientStop {
position: 1., position: 1.,
midpoint: 0.5, midpoint: 0.5,
color: end_color.to_gamma_srgb(), color: end_color,
}, },
]); ]);
@@ -3,7 +3,7 @@
pub use crate::gradient::*; pub use crate::gradient::*;
use core_types::ATTR_OPACITY; use core_types::ATTR_OPACITY;
use core_types::Color; use core_types::Color;
use core_types::color::Alpha; use core_types::color::{Alpha, SRGBA8};
use core_types::list::List; use core_types::list::List;
use core_types::transform::Transform; use core_types::transform::Transform;
use dyn_any::DynAny; use dyn_any::DynAny;
@@ -30,7 +30,7 @@ impl std::fmt::Display for Fill {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::None => write!(f, "None"), Self::None => write!(f, "None"),
Self::Solid(color) => write!(f, "#{} (Alpha: {}%)", color.to_rgb_hex_srgb(), color.a() * 100.), Self::Solid(color) => write!(f, "#{} (Alpha: {}%)", SRGBA8::from(*color).to_rgb_hex(), color.a() * 100.),
Self::Gradient(gradient) => write!(f, "{gradient}"), Self::Gradient(gradient) => write!(f, "{gradient}"),
} }
} }
@@ -161,19 +161,75 @@ impl From<Gradient> for Fill {
/// Can be None, a solid [Color], or a linear/radial [Gradient]. /// Can be None, a solid [Color], or a linear/radial [Gradient].
/// ///
/// In the future we'll probably also add a pattern fill. /// In the future we'll probably also add a pattern fill.
///
/// Use [`FillChoiceUI`] at the JS boundary.
#[repr(C)] #[repr(C)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Default, Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)] #[derive(Default, Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum FillChoice { pub enum FillChoice {
#[default] #[default]
None, None,
/// WARNING: Color is gamma, not linear!
Solid(Color), Solid(Color),
/// WARNING: Color stops are gamma, not linear!
Gradient(GradientStops), Gradient(GradientStops),
} }
// TODO: Deprecate [`FillChoice`] and keep this, renamed, as the main widget-controlling type
/// JS-boundary version of [`FillChoice`] where the solid color is [`SRGBA8`] and the gradient is [`GradientStopsUI`].
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
#[derive(Default, Debug, Clone, PartialEq, DynAny)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum FillChoiceUI {
#[default]
None,
Solid(SRGBA8),
Gradient(GradientStopsUI),
}
impl From<&FillChoice> for FillChoiceUI {
fn from(value: &FillChoice) -> Self {
match value {
FillChoice::None => Self::None,
FillChoice::Solid(color) => Self::Solid(SRGBA8::from(*color)),
FillChoice::Gradient(stops) => Self::Gradient(GradientStopsUI::from(stops)),
}
}
}
impl From<&FillChoiceUI> for FillChoice {
fn from(value: &FillChoiceUI) -> Self {
match value {
FillChoiceUI::None => Self::None,
FillChoiceUI::Solid(srgba) => Self::Solid(Color::from(*srgba)),
FillChoiceUI::Gradient(stops) => Self::Gradient(GradientStops::from(stops)),
}
}
}
impl FillChoiceUI {
pub fn as_solid(&self) -> Option<SRGBA8> {
let Self::Solid(c) = self else { return None };
Some(*c)
}
pub fn as_gradient(&self) -> Option<&GradientStopsUI> {
let Self::Gradient(g) = self else { return None };
Some(g)
}
/// Build a CSS `background-image` string (always a `linear-gradient(...)`) representing this fill, or `None` if the fill is [`FillChoiceUI::None`].
/// Solid colors become a degenerate gradient between the same color so the CSS variable can always be assigned to a `background-image`.
pub fn to_css_background_image(&self) -> Option<String> {
match self {
Self::None => None,
Self::Solid(srgba) => {
let hex = srgba.to_rgba_hex();
Some(format!("linear-gradient(#{hex}, #{hex})"))
}
Self::Gradient(stops) => Some(stops.to_css_linear_gradient()),
}
}
}
impl FillChoice { impl FillChoice {
pub fn as_solid(&self) -> Option<Color> { pub fn as_solid(&self) -> Option<Color> {
let Self::Solid(color) = self else { return None }; let Self::Solid(color) = self else { return None };
@@ -190,7 +246,7 @@ impl FillChoice {
match self { match self {
Self::None => None, Self::None => None,
Self::Solid(color) => { Self::Solid(color) => {
let hex = color.to_rgba_hex_srgb_from_gamma(); let hex = SRGBA8::from(*color).to_rgba_hex();
Some(format!("linear-gradient(#{hex}, #{hex})")) Some(format!("linear-gradient(#{hex}, #{hex})"))
} }
Self::Gradient(stops) => Some(stops.to_css_linear_gradient()), Self::Gradient(stops) => Some(stops.to_css_linear_gradient()),
@@ -526,7 +582,7 @@ impl Default for Stroke {
fn default() -> Self { fn default() -> Self {
Self { Self {
weight: 0., weight: 0.,
color: Some(Color::from_rgba8_srgb(0, 0, 0, 255)), color: Some(Color::BLACK),
dash_lengths: Vec::new(), dash_lengths: Vec::new(),
dash_offset: 0., dash_offset: 0.,
cap: StrokeCap::Butt, cap: StrokeCap::Butt,
@@ -553,7 +609,7 @@ impl std::fmt::Display for PathStyle {
let fill = &self.fill; let fill = &self.fill;
let stroke = match &self.stroke { let stroke = match &self.stroke {
Some(stroke) => format!("#{} (Weight: {} px)", stroke.color.map_or("None".to_string(), |c| c.to_rgba_hex_srgb()), stroke.weight), Some(stroke) => format!("#{} (Weight: {} px)", stroke.color.map_or("None".to_string(), |c| SRGBA8::from(c).to_rgba_hex()), stroke.weight),
None => "None".to_string(), None => "None".to_string(),
}; };
@@ -13,6 +13,7 @@ use crate::shader_runtime::ShaderRuntime;
use crate::texture_cache::TextureCache; use crate::texture_cache::TextureCache;
use anyhow::Result; use anyhow::Result;
use core_types::Color; use core_types::Color;
use core_types::color::SRGBA8;
use futures::lock::Mutex; use futures::lock::Mutex;
use glam::{Affine2, UVec2}; use glam::{Affine2, UVec2};
use graphene_application_io::{ApplicationIo, EditorApi}; use graphene_application_io::{ApplicationIo, EditorApi};
@@ -55,9 +56,9 @@ impl WgpuExecutor {
let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let [r, g, b, a] = background.unwrap_or(Color::TRANSPARENT).to_rgba8(); let SRGBA8 { red, green, blue, alpha } = background.unwrap_or(Color::TRANSPARENT).into();
let render_params = RenderParams { let render_params = RenderParams {
base_color: vello::peniko::Color::from_rgba8(r, g, b, a), base_color: vello::peniko::Color::from_rgba8(red, green, blue, alpha),
width: size.x, width: size.x,
height: size.y, height: size.y,
antialiasing_method: AaConfig::Msaa16, antialiasing_method: AaConfig::Msaa16,
@@ -120,7 +120,9 @@ impl RasterGpuToRasterCpuConverter {
let start = row * row_stride; let start = row * row_stride;
let row_slice = &view[start..start + row_bytes]; let row_slice = &view[start..start + row_bytes];
for px in row_slice.chunks_exact(4) { for px in row_slice.chunks_exact(4) {
cpu_data.push(Color::from_rgba8_srgb(px[0], px[1], px[2], px[3])); // `Image<Color>` pixels are stored linear-light with associated (premultiplied) alpha
let srgba = SRGBA8::new(px[0], px[1], px[2], px[3]);
cpu_data.push(Color::from(srgba).apply_opacity(px[3] as f32 / 255.));
} }
} }
@@ -2,6 +2,7 @@
use base64::Engine; use base64::Engine;
#[cfg(target_family = "wasm")] #[cfg(target_family = "wasm")]
use canvas_utils::{Canvas, CanvasHandle}; use canvas_utils::{Canvas, CanvasHandle};
use core_types::color::SRGBA8;
use core_types::list::{Item, List}; use core_types::list::{Item, List};
#[cfg(target_family = "wasm")] #[cfg(target_family = "wasm")]
use core_types::math::bbox::Bbox; use core_types::math::bbox::Bbox;
@@ -123,7 +124,15 @@ fn string_to_bytes(_: impl Ctx, string: String) -> List<u8> {
#[node_macro::node(category("Web Request"), name("Image to Bytes"))] #[node_macro::node(category("Web Request"), name("Image to Bytes"))]
fn image_to_bytes(_: impl Ctx, image: List<Raster<CPU>>) -> List<u8> { fn image_to_bytes(_: impl Ctx, image: List<Raster<CPU>>) -> List<u8> {
let Some(image) = image.element(0) else { return List::new() }; let Some(image) = image.element(0) else { return List::new() };
image.data.iter().flat_map(|color| color.to_rgba8_srgb()).map(Item::new_from_element).collect() image
.data
.iter()
.flat_map(|color| {
let SRGBA8 { red, green, blue, alpha } = (*color).into();
[red, green, blue, alpha]
})
.map(Item::new_from_element)
.collect()
} }
/// Loads binary from URLs and local asset paths. Returns a transparent placeholder if the resource fails to load, allowing rendering to continue. /// Loads binary from URLs and local asset paths. Returns a transparent placeholder if the resource fails to load, allowing rendering to continue.
@@ -154,7 +163,11 @@ fn decode_image(_: impl Ctx, data: Arc<[u8]>) -> List<Raster<CPU>> {
let image = Image { let image = Image {
data: image data: image
.chunks(4) .chunks(4)
.map(|pixel| Color::from_unassociated_alpha(pixel[0], pixel[1], pixel[2], pixel[3]).to_linear_srgb()) .map(|pixel| {
// Decoded bytes are unassociated gamma sRGB; premultiply in gamma then lift to linear
let a = pixel[3];
Color::from_gamma_srgb_channels(pixel[0] * a, pixel[1] * a, pixel[2] * a, a)
})
.collect(), .collect(),
width: image.width(), width: image.width(),
height: image.height(), height: image.height(),
+5 -4
View File
@@ -823,7 +823,8 @@ fn rgba_to_color(_: impl Ctx, _primary: (), red: Fraction, green: Fraction, blue
let blue = (blue as f32).clamp(0., 1.); let blue = (blue as f32).clamp(0., 1.);
let alpha = (alpha as f32).clamp(0., 1.); let alpha = (alpha as f32).clamp(0., 1.);
List::new_from_element(Color::from_rgbaf32_unchecked(red, green, blue, alpha)) // RGB user inputs are interpreted as sRGB display values; lift to linear-light for the internal `Color`
List::new_from_element(Color::from_gamma_srgb_channels(red, green, blue, alpha))
} }
/// Constructs a color value from hue, saturation, value, and alpha components given as numbers from 0 to 1. /// Constructs a color value from hue, saturation, value, and alpha components given as numbers from 0 to 1.
@@ -848,11 +849,11 @@ fn hsla_to_color(_: impl Ctx, _primary: (), hue: Fraction, #[default(1.)] satura
List::new_from_element(Color::from_hsla(hue, saturation, lightness, alpha)) List::new_from_element(Color::from_hsla(hue, saturation, lightness, alpha))
} }
/// Constructs a color value from an sRGB color code string, such as `#RRGGBB` or `#RRGGBBAA`. Invalid hex code strings produce no color. /// Constructs a color value from a CSS color string. Accepts hex (`#RRGGBB`, `#RRGGBBAA`, plus bare and shorthand variants), CSS named colors (like `red`), and functional notations (`rgb(...)`, `hsl(...)`, etc.). Invalid inputs produce no color.
#[node_macro::node(category("Color"), name("Hex to Color"))] #[node_macro::node(category("Color"), name("Hex to Color"))]
fn hex_to_color(_: impl Ctx, hex_code: String) -> List<Color> { fn hex_to_color(_: impl Ctx, hex_code: String) -> List<Color> {
match Color::from_hex_str(&hex_code) { match core_types::misc::parse_css_color(&hex_code) {
Some(c) => List::new_from_element(c), Some(color) => List::new_from_element(color),
None => List::new(), None => List::new(),
} }
} }
+102 -78
View File
@@ -5,8 +5,8 @@ use crate::cubic_spline::CubicSplines;
use core::fmt::Debug; use core::fmt::Debug;
#[cfg(feature = "std")] #[cfg(feature = "std")]
use core_types::list::List; use core_types::list::List;
use glam::{Vec3, Vec4}; use glam::Vec3;
use no_std_types::color::Color; use no_std_types::color::{Color, linear_to_srgb, srgb_to_linear};
use no_std_types::context::Ctx; use no_std_types::context::Ctx;
use no_std_types::registry::types::{AngleF32, PercentageF32, SignedPercentageF32}; use no_std_types::registry::types::{AngleF32, PercentageF32, SignedPercentageF32};
use node_macro::BufferStruct; use node_macro::BufferStruct;
@@ -63,7 +63,7 @@ fn luminance<T: Adjust<Color>>(
) -> T { ) -> T {
input.adjust(|color| { input.adjust(|color| {
let luminance = match luminance_calc { let luminance = match luminance_calc {
LuminanceCalculation::SRGB => color.luminance_srgb(), LuminanceCalculation::SRGB => color.luminance_rec_709(),
LuminanceCalculation::Perceptual => color.luminance_perceptual(), LuminanceCalculation::Perceptual => color.luminance_perceptual(),
LuminanceCalculation::AverageChannels => color.average_rgb_channels(), LuminanceCalculation::AverageChannels => color.average_rgb_channels(),
LuminanceCalculation::MinimumChannels => color.minimum_rgb_channels(), LuminanceCalculation::MinimumChannels => color.minimum_rgb_channels(),
@@ -91,7 +91,7 @@ fn gamma_correction<T: Adjust<Color>>(
inverse: bool, inverse: bool,
) -> T { ) -> T {
let exponent = if inverse { 1. / gamma } else { gamma }; let exponent = if inverse { 1. / gamma } else { gamma };
input.adjust(|color| color.gamma(exponent)); input.adjust(|color| color.apply_gamma_exponent(exponent));
input input
} }
@@ -161,7 +161,7 @@ fn brightness_contrast_classic<T: Adjust<Color>>(
let offset = brightness * contrast + brightness - contrast / 2.; let offset = brightness * contrast + brightness - contrast / 2.;
input.adjust(|color| color.to_gamma_srgb().map_rgb(|c| (c + c * contrast + offset).clamp(0., 1.)).to_linear_srgb()); input.adjust(|color| color.map_gamma_rgb(|c| (c + c * contrast + offset).clamp(0., 1.)));
input input
} }
@@ -240,7 +240,7 @@ fn brightness_contrast<T: Adjust<Color>>(
}); });
let lut_max = (combined_lut.len() - 1) as f32; let lut_max = (combined_lut.len() - 1) as f32;
input.adjust(|color| color.to_gamma_srgb().map_rgb(|c| combined_lut[(c * lut_max).round() as usize]).to_linear_srgb()); input.adjust(|color| color.map_gamma_rgb(|c| combined_lut[(c * lut_max).round() as usize]));
input input
} }
@@ -270,7 +270,8 @@ fn levels<T: Adjust<Color>>(
#[default(100.)] output_maximums: PercentageF32, #[default(100.)] output_maximums: PercentageF32,
) -> T { ) -> T {
image.adjust(|color| { image.adjust(|color| {
let color = color.to_gamma_srgb(); // Levels math operates in gamma space
let [mut r, mut g, mut b, a] = color.to_gamma_srgb_channels();
// Input Range (Range: 0-1) // Input Range (Range: 0-1)
let input_shadows = shadows / 100.; let input_shadows = shadows / 100.;
@@ -301,15 +302,24 @@ fn levels<T: Adjust<Color>>(
// Input levels (Range: 0-1) // Input levels (Range: 0-1)
let highlights_minus_shadows = (input_highlights - input_shadows).clamp(f32::EPSILON, 1.); let highlights_minus_shadows = (input_highlights - input_shadows).clamp(f32::EPSILON, 1.);
let color = color.map_rgb(|c| ((c - input_shadows).max(0.) / highlights_minus_shadows).min(1.)); let input_map = |c: f32| ((c - input_shadows).max(0.) / highlights_minus_shadows).min(1.);
r = input_map(r);
g = input_map(g);
b = input_map(b);
// Midtones (Range: 0-1) // Midtones gamma curve (Range: 0-1)
let color = color.gamma(gamma); let inverse_gamma = 1. / gamma.max(0.0001);
r = r.powf(inverse_gamma);
g = g.powf(inverse_gamma);
b = b.powf(inverse_gamma);
// Output levels (Range: 0-1) // Output levels (Range: 0-1)
let color = color.map_rgb(|c| c * (output_maximums - output_minimums) + output_minimums); let output_map = |c: f32| c * (output_maximums - output_minimums) + output_minimums;
r = output_map(r);
g = output_map(g);
b = output_map(b);
color.to_linear_srgb() Color::from_gamma_srgb_channels(r, g, b, a)
}); });
image image
} }
@@ -353,7 +363,8 @@ fn black_and_white<T: Adjust<Color>>(
magentas: PercentageF32, magentas: PercentageF32,
) -> T { ) -> T {
image.adjust(|color| { image.adjust(|color| {
let color = color.to_gamma_srgb(); // Black & White channel weights are tuned for gamma-space values
let [r, g, b, alpha_part] = color.to_gamma_srgb_channels();
let reds = reds / 100.; let reds = reds / 100.;
let yellows = yellows / 100.; let yellows = yellows / 100.;
@@ -362,12 +373,11 @@ fn black_and_white<T: Adjust<Color>>(
let blues = blues / 100.; let blues = blues / 100.;
let magentas = magentas / 100.; let magentas = magentas / 100.;
let gray_base = color.r().min(color.g()).min(color.b()); let gray_base = r.min(g).min(b);
let red_part = color.r() - gray_base; let red_part = r - gray_base;
let green_part = color.g() - gray_base; let green_part = g - gray_base;
let blue_part = color.b() - gray_base; let blue_part = b - gray_base;
let alpha_part = color.a();
let additional = if red_part == 0. { let additional = if red_part == 0. {
let cyan_part = green_part.min(blue_part); let cyan_part = green_part.min(blue_part);
@@ -383,11 +393,15 @@ fn black_and_white<T: Adjust<Color>>(
let luminance = gray_base + additional; let luminance = gray_base + additional;
// TODO: Fix "Color" blend mode implementation so it matches the expected behavior perfectly (it's currently close) // TODO: Fix "Color" blend mode implementation so it matches the expected behavior perfectly (it's currently close)
let color = tint.with_luminance(luminance); // Apply luminance substitution in gamma space
let [tr, tg, tb, _] = tint.to_gamma_srgb_channels();
let tint_luma_rec_601 = 0.3 * tr + 0.59 * tg + 0.11 * tb;
let delta = luminance - tint_luma_rec_601;
let result_r = (tr + delta).clamp(0., 1.);
let result_g = (tg + delta).clamp(0., 1.);
let result_b = (tb + delta).clamp(0., 1.);
let color = Color::from_rgbaf32_unchecked(color.r(), color.g(), color.b(), alpha_part); Color::from_gamma_srgb_channels(result_r, result_g, result_b, alpha_part)
color.to_linear_srgb()
}); });
image image
} }
@@ -410,20 +424,17 @@ fn hue_saturation<T: Adjust<Color>>(
lightness_shift: SignedPercentageF32, lightness_shift: SignedPercentageF32,
) -> T { ) -> T {
input.adjust(|color| { input.adjust(|color| {
let color = color.to_gamma_srgb(); // HSL operates on gamma-space channels
let [hue, saturation, lightness, alpha] = color.to_hsla(); let [hue, saturation, lightness, alpha] = color.to_hsla();
let color = Color::from_hsla( Color::from_hsla(
(hue + hue_shift / 360.) % 1., (hue + hue_shift / 360.) % 1.,
// TODO: Improve the way saturation works (it's slightly off) // TODO: Improve the way saturation works (it's slightly off)
(saturation + saturation_shift / 100.).clamp(0., 1.), (saturation + saturation_shift / 100.).clamp(0., 1.),
// TODO: Fix the way lightness works (it's very off) // TODO: Fix the way lightness works (it's very off)
(lightness + lightness_shift / 100.).clamp(0., 1.), (lightness + lightness_shift / 100.).clamp(0., 1.),
alpha, alpha,
); )
color.to_linear_srgb()
}); });
input input
} }
@@ -442,11 +453,9 @@ fn invert<T: Adjust<Color>>(
mut input: T, mut input: T,
) -> T { ) -> T {
input.adjust(|color| { input.adjust(|color| {
let color = color.to_gamma_srgb(); // Invert in gamma space relative to alpha
let [r, g, b, a] = color.to_gamma_srgb_channels();
let color = color.map_rgb(|c| color.a() - c); Color::from_gamma_srgb_channels(a - r, a - g, a - b, a)
color.to_linear_srgb()
}); });
input input
} }
@@ -468,11 +477,11 @@ fn threshold<T: Adjust<Color>>(
luminance_calc: LuminanceCalculation, luminance_calc: LuminanceCalculation,
) -> T { ) -> T {
image.adjust(|color| { image.adjust(|color| {
let min_luminance = Color::srgb_to_linear(min_luminance / 100.); let min_luminance = srgb_to_linear(min_luminance / 100.);
let max_luminance = Color::srgb_to_linear(max_luminance / 100.); let max_luminance = srgb_to_linear(max_luminance / 100.);
let luminance = match luminance_calc { let luminance = match luminance_calc {
LuminanceCalculation::SRGB => color.luminance_srgb(), LuminanceCalculation::SRGB => color.luminance_rec_709(),
LuminanceCalculation::Perceptual => color.luminance_perceptual(), LuminanceCalculation::Perceptual => color.luminance_perceptual(),
LuminanceCalculation::AverageChannels => color.average_rgb_channels(), LuminanceCalculation::AverageChannels => color.average_rgb_channels(),
LuminanceCalculation::MinimumChannels => color.minimum_rgb_channels(), LuminanceCalculation::MinimumChannels => color.minimum_rgb_channels(),
@@ -512,17 +521,22 @@ fn vibrance<T: Adjust<Color>>(
vibrance: SignedPercentageF32, vibrance: SignedPercentageF32,
) -> T { ) -> T {
image.adjust(|color| { image.adjust(|color| {
let r_raw = color.r();
let g_raw = color.g();
let b_raw = color.b();
let alpha_in = color.a();
let vibrance = vibrance / 100.; let vibrance = vibrance / 100.;
// Slow the effect down by half when it's negative, since artifacts begin appearing past -50%. // Slow the effect down by half when it's negative, since artifacts begin appearing past -50%.
// So this scales the 0% to -50% range to 0% to -100%. // So this scales the 0% to -50% range to 0% to -100%.
let slowed_vibrance = if vibrance >= 0. { vibrance } else { vibrance * 0.5 }; let slowed_vibrance = if vibrance >= 0. { vibrance } else { vibrance * 0.5 };
let channel_max = color.r().max(color.g()).max(color.b()); let channel_max = r_raw.max(g_raw).max(b_raw);
let channel_min = color.r().min(color.g()).min(color.b()); let channel_min = r_raw.min(g_raw).min(b_raw);
let channel_difference = channel_max - channel_min; let channel_difference = channel_max - channel_min;
let scale_multiplier = if channel_max == color.r() { let scale_multiplier = if channel_max == r_raw {
let green_blue_difference = (color.g() - color.b()).abs(); let green_blue_difference = (g_raw - b_raw).abs();
let t = (green_blue_difference / channel_difference).min(1.); let t = (green_blue_difference / channel_difference).min(1.);
t * 0.5 + 0.5 t * 0.5 + 0.5
} else { } else {
@@ -532,30 +546,48 @@ fn vibrance<T: Adjust<Color>>(
let channel_reduction = channel_min * scale; let channel_reduction = channel_min * scale;
let scale = 1. + scale * (1. - channel_difference); let scale = 1. + scale * (1. - channel_difference);
let luminance_initial = color.to_linear_srgb().luminance_srgb(); let r_lin0 = srgb_to_linear(r_raw);
let altered_color = color.map_rgb(|c| c * scale - channel_reduction).to_linear_srgb(); let g_lin0 = srgb_to_linear(g_raw);
let luminance = altered_color.luminance_srgb(); let b_lin0 = srgb_to_linear(b_raw);
let altered_color = altered_color.map_rgb(|c| c * luminance_initial / luminance); let luminance_initial = 0.2126 * r_lin0 + 0.7152 * g_lin0 + 0.0722 * b_lin0;
let channel_max = altered_color.r().max(altered_color.g()).max(altered_color.b()); let mut alt_r = srgb_to_linear(r_raw * scale - channel_reduction);
let altered_color = if Color::linear_to_srgb(channel_max) > 1. { let mut alt_g = srgb_to_linear(g_raw * scale - channel_reduction);
let mut alt_b = srgb_to_linear(b_raw * scale - channel_reduction);
let luminance = 0.2126 * alt_r + 0.7152 * alt_g + 0.0722 * alt_b;
// Skip the luminance-preservation scaling when the result is black (e.g. black input pixel), avoiding division by zero.
if luminance > 0. {
alt_r *= luminance_initial / luminance;
alt_g *= luminance_initial / luminance;
alt_b *= luminance_initial / luminance;
}
let channel_max = alt_r.max(alt_g).max(alt_b);
if linear_to_srgb(channel_max) > 1. {
let scale = (1. - luminance) / (channel_max - luminance); let scale = (1. - luminance) / (channel_max - luminance);
altered_color.map_rgb(|c| (c - luminance) * scale + luminance) alt_r = (alt_r - luminance) * scale + luminance;
} else { alt_g = (alt_g - luminance) * scale + luminance;
altered_color alt_b = (alt_b - luminance) * scale + luminance;
}; }
let altered_color = altered_color.to_gamma_srgb();
alt_r = linear_to_srgb(alt_r);
alt_g = linear_to_srgb(alt_g);
alt_b = linear_to_srgb(alt_b);
if vibrance >= 0. { if vibrance >= 0. {
altered_color Color::from_rgbaf32_unchecked(alt_r, alt_g, alt_b, alpha_in)
} else { } else {
// TODO: The result ends up a bit darker than it should be, further investigation is needed // TODO: The result ends up a bit darker than it should be, further investigation is needed.
let luminance = color.luminance_rec_601(); // Mix in gamma space (matching `alt_*`), so the luminance is computed from gamma channels too.
let [gr, gg, gb, _] = color.to_gamma_srgb_channels();
// Near -0% vibrance we mostly use `altered_color`. let luminance = 0.299 * gr + 0.587 * gg + 0.114 * gb;
// Near -100% vibrance, we mostly use half the desaturated luminance color and half `altered_color`.
let factor = -slowed_vibrance; let factor = -slowed_vibrance;
altered_color.map_rgb(|c| c * (1. - factor) + luminance * factor) Color::from_rgbaf32_unchecked(
alt_r * (1. - factor) + luminance * factor,
alt_g * (1. - factor) + luminance * factor,
alt_b * (1. - factor) + luminance * factor,
alpha_in,
)
} }
}); });
image image
@@ -747,16 +779,14 @@ fn channel_mixer<T: Adjust<Color>>(
_output_channel: RedGreenBlue, _output_channel: RedGreenBlue,
) -> T { ) -> T {
image.adjust(|color| { image.adjust(|color| {
let color = color.to_gamma_srgb(); let [r, g, b, a] = color.to_gamma_srgb_channels();
let (r, g, b, a) = color.components(); let (out_r, out_g, out_b) = if monochrome {
let color = if monochrome {
let (monochrome_r, monochrome_g, monochrome_b, monochrome_c) = (monochrome_r / 100., monochrome_g / 100., monochrome_b / 100., monochrome_c / 100.); let (monochrome_r, monochrome_g, monochrome_b, monochrome_c) = (monochrome_r / 100., monochrome_g / 100., monochrome_b / 100., monochrome_c / 100.);
let gray = (r * monochrome_r + g * monochrome_g + b * monochrome_b + monochrome_c).clamp(0., 1.); let gray = (r * monochrome_r + g * monochrome_g + b * monochrome_b + monochrome_c).clamp(0., 1.);
Color::from_rgbaf32_unchecked(gray, gray, gray, a) (gray, gray, gray)
} else { } else {
let (red_r, red_g, red_b, red_c) = (red_r / 100., red_g / 100., red_b / 100., red_c / 100.); let (red_r, red_g, red_b, red_c) = (red_r / 100., red_g / 100., red_b / 100., red_c / 100.);
let (green_r, green_g, green_b, green_c) = (green_r / 100., green_g / 100., green_b / 100., green_c / 100.); let (green_r, green_g, green_b, green_c) = (green_r / 100., green_g / 100., green_b / 100., green_c / 100.);
@@ -766,10 +796,10 @@ fn channel_mixer<T: Adjust<Color>>(
let green = (r * green_r + g * green_g + b * green_b + green_c).clamp(0., 1.); let green = (r * green_r + g * green_g + b * green_b + green_c).clamp(0., 1.);
let blue = (r * blue_r + g * blue_g + b * blue_b + blue_c).clamp(0., 1.); let blue = (r * blue_r + g * blue_g + b * blue_b + blue_c).clamp(0., 1.);
Color::from_rgbaf32_unchecked(red, green, blue, a) (red, green, blue)
}; };
color.to_linear_srgb() Color::from_gamma_srgb_channels(out_r, out_g, out_b, a)
}); });
image image
} }
@@ -873,9 +903,7 @@ fn selective_color<T: Adjust<Color>>(
_colors: SelectiveColorChoice, _colors: SelectiveColorChoice,
) -> T { ) -> T {
image.adjust(|color| { image.adjust(|color| {
let color = color.to_gamma_srgb(); let [r, g, b, a] = color.to_gamma_srgb_channels();
let (r, g, b, a) = color.components();
let min = |a: f32, b: f32, c: f32| a.min(b).min(c); let min = |a: f32, b: f32, c: f32| a.min(b).min(c);
let max = |a: f32, b: f32, c: f32| a.max(b).max(c); let max = |a: f32, b: f32, c: f32| a.max(b).max(c);
@@ -945,9 +973,9 @@ fn selective_color<T: Adjust<Color>>(
} }
let rgb = Vec3::new(r, g, b); let rgb = Vec3::new(r, g, b);
let color = Color::from_vec4(Vec4::from(((sum + rgb).clamp(Vec3::ZERO, Vec3::ONE), a))); let out = (sum + rgb).clamp(Vec3::ZERO, Vec3::ONE);
color.to_linear_srgb() Color::from_gamma_srgb_channels(out.x, out.y, out.z, a)
}); });
image image
} }
@@ -973,15 +1001,11 @@ fn posterize<T: Adjust<Color>>(
levels: u32, levels: u32,
) -> T { ) -> T {
input.adjust(|color| { input.adjust(|color| {
let color = color.to_gamma_srgb(); // `hard_min(2)` constrains the widget but doesn't bind the data-flow input (a saved doc or upstream node could still feed 0 or 1, producing inf/NaN below).
let levels = (levels as f32).max(2.);
let levels = levels as f32;
let number_of_areas = levels.recip(); let number_of_areas = levels.recip();
let size_of_areas = (levels - 1.).recip(); let size_of_areas = (levels - 1.).recip();
let channel = |channel: f32| (channel / number_of_areas).floor() * size_of_areas; color.map_gamma_rgb(|c| (c / number_of_areas).floor() * size_of_areas)
let color = color.map_rgb(channel);
color.to_linear_srgb()
}); });
input input
} }
@@ -1016,7 +1040,7 @@ fn exposure<T: Adjust<Color>>(
// Offset // Offset
.map_rgb(|c: f32| c + offset) .map_rgb(|c: f32| c + offset)
// Gamma correction // Gamma correction
.gamma(gamma_correction); .apply_gamma_exponent(gamma_correction);
adjusted.map_rgb(|c: f32| c.clamp(0., 1.)) adjusted.map_rgb(|c: f32| c.clamp(0., 1.))
}); });
@@ -99,7 +99,7 @@ pub fn blend_colors(foreground: Color, background: Color, blend_mode: BlendMode,
blend_mode => apply_blend_mode(foreground, background, blend_mode), blend_mode => apply_blend_mode(foreground, background, blend_mode),
}; };
background.alpha_blend(target_color.to_associated_alpha(opacity)) background.alpha_blend(target_color.apply_opacity(opacity))
} }
pub fn apply_blend_mode(foreground: Color, background: Color, blend_mode: BlendMode) -> Color { pub fn apply_blend_mode(foreground: Color, background: Color, blend_mode: BlendMode) -> Color {
+128 -41
View File
@@ -1,4 +1,5 @@
use core_types::color::Color; use bytemuck::{Pod, Zeroable};
use core_types::color::{Alpha, Color, Pixel, RGB};
use core_types::context::Ctx; use core_types::context::Ctx;
use core_types::list::List; use core_types::list::List;
use core_types::registry::types::PixelLength; use core_types::registry::types::PixelLength;
@@ -6,6 +7,84 @@ use raster_types::Image;
use raster_types::{Bitmap, BitmapMut}; use raster_types::{Bitmap, BitmapMut};
use raster_types::{CPU, Raster}; use raster_types::{CPU, Raster};
/// Working-buffer pixel for the blur algorithms' `gamma` mode: premultiplied sRGB-gamma `f32` channels.
/// Only used internally so the working buffer's color space is reflected in the type instead of stuffed into `Color` (which is linear-light by invariant).
#[repr(C)]
#[derive(Debug, Default, Clone, Copy, PartialEq, Pod, Zeroable)]
struct PremultipliedGammaPixel {
r: f32,
g: f32,
b: f32,
a: f32,
}
impl Pixel for PremultipliedGammaPixel {}
impl RGB for PremultipliedGammaPixel {
type ColorChannel = f32;
fn red(&self) -> f32 {
self.r
}
fn green(&self) -> f32 {
self.g
}
fn blue(&self) -> f32 {
self.b
}
}
impl Alpha for PremultipliedGammaPixel {
type AlphaChannel = f32;
const TRANSPARENT: Self = Self { r: 0., g: 0., b: 0., a: 0. };
fn alpha(&self) -> f32 {
self.a
}
fn multiplied_alpha(&self, mult: f32) -> Self {
Self {
r: self.r * mult,
g: self.g * mult,
b: self.b * mult,
a: self.a * mult,
}
}
}
fn premultiply_gamma(buffer: Image<Color>) -> Image<PremultipliedGammaPixel> {
Image {
width: buffer.width,
height: buffer.height,
data: buffer
.data
.into_iter()
.map(|px| {
let [r, g, b, a] = px.to_gamma_srgb_channels();
PremultipliedGammaPixel { r: r * a, g: g * a, b: b * a, a }
})
.collect(),
base64_string: None,
}
}
fn unpremultiply_gamma_to_linear(buffer: Image<PremultipliedGammaPixel>) -> Image<Color> {
Image {
width: buffer.width,
height: buffer.height,
data: buffer
.data
.into_iter()
.map(|px| {
if px.a > 0. {
let inv_a = 1. / px.a;
Color::from_gamma_srgb_channels(px.r * inv_a, px.g * inv_a, px.b * inv_a, px.a)
} else {
Color::TRANSPARENT
}
})
.collect(),
base64_string: None,
}
}
/// Blurs the image with a Gaussian or box blur kernel filter. /// Blurs the image with a Gaussian or box blur kernel filter.
#[node_macro::node(category("Raster: Filter"))] #[node_macro::node(category("Raster: Filter"))]
async fn blur( async fn blur(
@@ -98,26 +177,49 @@ fn gaussian_kernel(radius: f64) -> Vec<f64> {
gaussian_kernel gaussian_kernel
} }
fn gaussian_blur_algorithm(mut original_buffer: Image<Color>, radius: f64, gamma: bool) -> Image<Color> { fn gaussian_blur_algorithm(buffer: Image<Color>, radius: f64, gamma: bool) -> Image<Color> {
if gamma {
original_buffer.map_pixels(|px| px.to_gamma_srgb().to_associated_alpha(px.a()));
} else {
original_buffer.map_pixels(|px| px.to_associated_alpha(px.a()));
}
let (width, height) = original_buffer.dimensions();
// Create 1D gaussian kernel
let kernel = gaussian_kernel(radius); let kernel = gaussian_kernel(radius);
if gamma {
let working = premultiply_gamma(buffer);
let blurred = gaussian_separable(working, &kernel, |r, g, b, a| PremultipliedGammaPixel { r, g, b, a });
unpremultiply_gamma_to_linear(blurred)
} else {
let mut working = buffer;
working.map_pixels(|px| px.apply_opacity(px.a()));
let mut blurred = gaussian_separable(working, &kernel, Color::from_rgbaf32_unchecked);
blurred.map_pixels(|px| px.to_unassociated_alpha());
blurred
}
}
fn box_blur_algorithm(buffer: Image<Color>, radius: f64, gamma: bool) -> Image<Color> {
if gamma {
let working = premultiply_gamma(buffer);
let blurred = box_separable(working, radius, |r, g, b, a| PremultipliedGammaPixel { r, g, b, a });
unpremultiply_gamma_to_linear(blurred)
} else {
let mut working = buffer;
working.map_pixels(|px| px.apply_opacity(px.a()));
let mut blurred = box_separable(working, radius, Color::from_rgbaf32_unchecked);
blurred.map_pixels(|px| px.to_unassociated_alpha());
blurred
}
}
fn gaussian_separable<P, F>(buffer: Image<P>, kernel: &[f64], construct: F) -> Image<P>
where
P: Pixel + Copy + RGB<ColorChannel = f32> + Alpha<AlphaChannel = f32>,
F: Fn(f32, f32, f32, f32) -> P,
{
let (width, height) = buffer.dimensions();
let half_kernel = kernel.len() / 2; let half_kernel = kernel.len() / 2;
// Intermediate buffer for horizontal and vertical passes let mut x_axis = Image::new(width, height, P::default());
let mut x_axis = Image::new(width, height, Color::TRANSPARENT); let mut y_axis = Image::new(width, height, P::default());
let mut y_axis = Image::new(width, height, Color::TRANSPARENT);
for pass in [false, true] { for pass in [false, true] {
let (max, old_buffer, current_buffer) = match pass { let (max, old_buffer, current_buffer) = match pass {
false => (width, &original_buffer, &mut x_axis), false => (width, &buffer, &mut x_axis),
true => (height, &x_axis, &mut y_axis), true => (height, &x_axis, &mut y_axis),
}; };
let pass = pass as usize; let pass = pass as usize;
@@ -140,41 +242,32 @@ fn gaussian_blur_algorithm(mut original_buffer: Image<Color>, radius: f64, gamma
} }
} }
// Normalize
let (r, g, b, a) = if weight_sum > 0. { let (r, g, b, a) = if weight_sum > 0. {
((r_sum / weight_sum) as f32, (g_sum / weight_sum) as f32, (b_sum / weight_sum) as f32, (a_sum / weight_sum) as f32) ((r_sum / weight_sum) as f32, (g_sum / weight_sum) as f32, (b_sum / weight_sum) as f32, (a_sum / weight_sum) as f32)
} else { } else {
let px = old_buffer.get_pixel(x, y).unwrap(); let px = old_buffer.get_pixel(x, y).unwrap();
(px.r(), px.g(), px.b(), px.a()) (px.r(), px.g(), px.b(), px.a())
}; };
current_buffer.set_pixel(x, y, Color::from_rgbaf32_unchecked(r, g, b, a)); current_buffer.set_pixel(x, y, construct(r, g, b, a));
} }
} }
} }
if gamma {
y_axis.map_pixels(|px| px.to_linear_srgb().to_unassociated_alpha());
} else {
y_axis.map_pixels(|px| px.to_unassociated_alpha());
}
y_axis y_axis
} }
fn box_blur_algorithm(mut original_buffer: Image<Color>, radius: f64, gamma: bool) -> Image<Color> { fn box_separable<P, F>(buffer: Image<P>, radius: f64, construct: F) -> Image<P>
if gamma { where
original_buffer.map_pixels(|px| px.to_gamma_srgb().to_associated_alpha(px.a())); P: Pixel + Copy + RGB<ColorChannel = f32> + Alpha<AlphaChannel = f32>,
} else { F: Fn(f32, f32, f32, f32) -> P,
original_buffer.map_pixels(|px| px.to_associated_alpha(px.a())); {
} let (width, height) = buffer.dimensions();
let mut x_axis = Image::new(width, height, P::default());
let (width, height) = original_buffer.dimensions(); let mut y_axis = Image::new(width, height, P::default());
let mut x_axis = Image::new(width, height, Color::TRANSPARENT);
let mut y_axis = Image::new(width, height, Color::TRANSPARENT);
for pass in [false, true] { for pass in [false, true] {
let (max, old_buffer, current_buffer) = match pass { let (max, old_buffer, current_buffer) = match pass {
false => (width, &original_buffer, &mut x_axis), false => (width, &buffer, &mut x_axis),
true => (height, &x_axis, &mut y_axis), true => (height, &x_axis, &mut y_axis),
}; };
let pass = pass as usize; let pass = pass as usize;
@@ -196,17 +289,11 @@ fn box_blur_algorithm(mut original_buffer: Image<Color>, radius: f64, gamma: boo
} }
let (r, g, b, a) = ((r_sum / weight_sum) as f32, (g_sum / weight_sum) as f32, (b_sum / weight_sum) as f32, (a_sum / weight_sum) as f32); let (r, g, b, a) = ((r_sum / weight_sum) as f32, (g_sum / weight_sum) as f32, (b_sum / weight_sum) as f32, (a_sum / weight_sum) as f32);
current_buffer.set_pixel(x, y, Color::from_rgbaf32_unchecked(r, g, b, a)); current_buffer.set_pixel(x, y, construct(r, g, b, a));
} }
} }
} }
if gamma {
y_axis.map_pixels(|px| px.to_linear_srgb().to_unassociated_alpha());
} else {
y_axis.map_pixels(|px| px.to_unassociated_alpha());
}
y_axis y_axis
} }
+2 -2
View File
@@ -24,9 +24,9 @@ async fn gradient_map<T: Adjust<Color>>(
let Some(gradient) = gradient.element(0) else { return image }; let Some(gradient) = gradient.element(0) else { return image };
image.adjust(|color| { image.adjust(|color| {
let intensity = color.luminance_srgb(); let intensity = color.luminance_rec_709();
let intensity = if reverse { 1. - intensity } else { intensity }; let intensity = if reverse { 1. - intensity } else { intensity };
gradient.evaluate(intensity as f64).to_linear_srgb() gradient.evaluate(intensity as f64)
}); });
image image
@@ -16,7 +16,8 @@ async fn image_color_palette(
let bins = GRID * GRID * GRID; let bins = GRID * GRID * GRID;
let mut histogram = vec![0; (bins + 1.) as usize]; let mut histogram = vec![0; (bins + 1.) as usize];
let mut color_bins = vec![Vec::new(); (bins + 1.) as usize]; // Each bin stores `(red, green, blue, alpha)` tuples in sRGB gamma space; averaging in gamma space gives perceptually-uniform binning.
let mut color_bins: Vec<Vec<[f32; 4]>> = vec![Vec::new(); (bins + 1.) as usize];
for element in image.iter_element_values() { for element in image.iter_element_values() {
for pixel in element.data.iter() { for pixel in element.data.iter() {
@@ -27,7 +28,7 @@ async fn image_color_palette(
let bin = (r * GRID + g * GRID + b * GRID) as usize; let bin = (r * GRID + g * GRID + b * GRID) as usize;
histogram[bin] += 1; histogram[bin] += 1;
color_bins[bin].push(pixel.to_gamma_srgb()); color_bins[bin].push(pixel.to_gamma_srgb_channels());
} }
} }
@@ -39,24 +40,21 @@ async fn image_color_palette(
.flat_map(|&i| { .flat_map(|&i| {
let list = &color_bins[i]; let list = &color_bins[i];
let mut r = 0.; let [mut r, mut g, mut b, mut a] = [0.; 4];
let mut g = 0.;
let mut b = 0.;
let mut a = 0.;
for color in list.iter() { for &[cr, cg, cb, ca] in list.iter() {
r += color.r(); r += cr;
g += color.g(); g += cg;
b += color.b(); b += cb;
a += color.a(); a += ca;
} }
r /= list.len() as f32; let len = list.len() as f32;
g /= list.len() as f32; let [r, g, b, a] = [r / len, g / len, b / len, a / len];
b /= list.len() as f32;
a /= list.len() as f32;
Color::from_rgbaf32(r, g, b, a).map(Item::new_from_element).into_iter() // Reject NaN/out-of-range averages, then lift the gamma-space bin centroid to linear-light
let in_gamut = a <= 1. && ![r, g, b, a].iter().any(|c| c.is_sign_negative() || !c.is_finite());
in_gamut.then(|| Color::from_gamma_srgb_channels(r, g, b, a)).map(Item::new_from_element).into_iter()
}) })
.collect() .collect()
} }