Skip to content

Commit a56746c

Browse files
authored
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
1 parent 456a7c8 commit a56746c

67 files changed

Lines changed: 1204 additions & 935 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

editor/src/messages/color_picker/color_picker_message.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ pub enum HsvChannel {
2121
#[impl_message(Message, ColorPicker)]
2222
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
2323
pub enum ColorPickerMessage {
24-
/// Initialize the picker state from an external color/gradient and announce its options. Called by the frontend when a `<ColorPicker>` opens.
24+
/// Initialize the picker state from an external color/gradient and announce its options. Called by the frontend when a `<ColorPicker />` opens.
2525
Open { initial_value: FillChoice, allow_none: bool, disabled: bool },
2626
/// Clear the picker state. Called by the frontend when the popover closes.
2727
Close,

editor/src/messages/color_picker/color_picker_message_handler.rs

Lines changed: 52 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ use crate::messages::color_picker::color_picker_message::{HsvChannel, RgbChannel
22
use crate::messages::layout::utility_types::widget_prelude::*;
33
use crate::messages::layout::utility_types::widgets::input_widgets::{ColorPresetsInputUpdate, SpectrumInputUpdate, SpectrumMarker, VisualColorPickersInputUpdate};
44
use crate::messages::prelude::*;
5-
use color::{AlphaColor, Srgb};
65
use graphene_std::Color;
7-
use graphene_std::vector::style::{FillChoice, GradientStops};
6+
use graphene_std::color::SRGBA8;
7+
use graphene_std::core_types::misc::parse_css_color;
8+
use graphene_std::vector::style::{FillChoice, FillChoiceUI, GradientStops, GradientStopsUI};
89

910
/// Bounds for a midpoint position (relative to the interval between two adjacent gradient stops).
1011
const MIN_MIDPOINT: f64 = 0.01;
@@ -105,10 +106,13 @@ impl MessageHandler<ColorPickerMessage, ()> for ColorPickerMessageHandler {
105106
ColorPickerMessage::SetChannelRgb { channel, value } => {
106107
let Some(strength) = value else { return };
107108
let Some(current) = self.current_color() else { return };
109+
// The RGB inputs are 0..255 sRGB display values; substitute the new channel into the gamma triple and lift back to linear for storage.
110+
let new_gamma_channel = (strength / 255.) as f32;
111+
let [cur_r, cur_g, cur_b, cur_a] = current.to_gamma_srgb_channels();
108112
let updated = match channel {
109-
RgbChannel::Red => Color::from_rgbaf32_unchecked((strength / 255.) as f32, current.g(), current.b(), current.a()),
110-
RgbChannel::Green => Color::from_rgbaf32_unchecked(current.r(), (strength / 255.) as f32, current.b(), current.a()),
111-
RgbChannel::Blue => Color::from_rgbaf32_unchecked(current.r(), current.g(), (strength / 255.) as f32, current.a()),
113+
RgbChannel::Red => Color::from_gamma_srgb_channels(new_gamma_channel, cur_g, cur_b, cur_a),
114+
RgbChannel::Green => Color::from_gamma_srgb_channels(cur_r, new_gamma_channel, cur_b, cur_a),
115+
RgbChannel::Blue => Color::from_gamma_srgb_channels(cur_r, cur_g, new_gamma_channel, cur_a),
112116
};
113117
self.adopt_color(updated);
114118
self.emit_color(responses);
@@ -150,7 +154,7 @@ impl MessageHandler<ColorPickerMessage, ()> for ColorPickerMessageHandler {
150154
match preset {
151155
FillChoice::None => {
152156
self.set_new_hsva(0., 0., 0., 1., true);
153-
responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoice::None });
157+
responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoiceUI::None });
154158
}
155159
FillChoice::Solid(color) => {
156160
self.adopt_color(color);
@@ -175,7 +179,7 @@ impl MessageHandler<ColorPickerMessage, ()> for ColorPickerMessageHandler {
175179
self.set_old_hsva(temp.0, temp.1, temp.2, temp.3, temp.4);
176180

177181
if self.is_none {
178-
responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoice::None });
182+
responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoiceUI::None });
179183
} else {
180184
self.emit_color(responses);
181185
}
@@ -197,6 +201,7 @@ impl MessageHandler<ColorPickerMessage, ()> for ColorPickerMessageHandler {
197201
}
198202

199203
impl ColorPickerMessageHandler {
204+
// The picker's internal HSV state is HSV of sRGB display values
200205
fn current_color(&self) -> Option<Color> {
201206
if self.is_none {
202207
None
@@ -239,7 +244,8 @@ impl ColorPickerMessageHandler {
239244

240245
/// Set HSV state from a Color, preserving hue and saturation in degenerate cases.
241246
fn adopt_color(&mut self, color: Color) {
242-
let [target_h, target_s, target_v] = rgb_to_hsv(color.r() as f64, color.g() as f64, color.b() as f64);
247+
let [target_h, target_s, target_v, target_a] = color.to_hsva();
248+
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);
243249

244250
// Preserve hue: avoid jumping from 360° (top) to 0° (bottom) and don't reset hue when the color is desaturated or fully dark.
245251
if !(target_h == 0. && self.hue == 1.) && target_s > 0. && target_v > 0. {
@@ -250,7 +256,7 @@ impl ColorPickerMessageHandler {
250256
self.saturation = target_s;
251257
}
252258
self.value = target_v;
253-
self.alpha = color.a() as f64;
259+
self.alpha = target_a;
254260
self.is_none = false;
255261
}
256262

@@ -264,9 +270,15 @@ impl ColorPickerMessageHandler {
264270
{
265271
*stop_color = color;
266272
let stops = gradient.clone();
267-
responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoice::Gradient(stops) });
273+
let fill_choice = FillChoice::Gradient(stops);
274+
responses.add(FrontendMessage::ColorPickerColorChanged {
275+
value: FillChoiceUI::from(&fill_choice),
276+
});
268277
} else {
269-
responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoice::Solid(color) });
278+
let fill_choice = FillChoice::Solid(color);
279+
responses.add(FrontendMessage::ColorPickerColorChanged {
280+
value: FillChoiceUI::from(&fill_choice),
281+
});
270282
}
271283
}
272284

@@ -364,8 +376,9 @@ impl ColorPickerMessageHandler {
364376
}
365377

366378
self.gradient = Some(gradient.clone());
379+
let fill_choice = FillChoice::Gradient(gradient);
367380
responses.add(FrontendMessage::ColorPickerColorChanged {
368-
value: FillChoice::Gradient(gradient),
381+
value: FillChoiceUI::from(&fill_choice),
369382
});
370383
self.send_layouts(responses);
371384
}
@@ -392,7 +405,7 @@ impl ColorPickerMessageHandler {
392405
// For gradient editing, the markers' handle colors mirror their gradient stop colors
393406
let markers = gradient.iter().map(|stop| SpectrumMarker::new(stop.position, stop.midpoint, stop.color)).collect();
394407
let mut row_widgets = vec![
395-
SpectrumInput::new(gradient.clone())
408+
SpectrumInput::new(GradientStopsUI::from(gradient))
396409
.markers(markers)
397410
.active_marker_index(self.active_marker_index)
398411
.active_marker_is_midpoint(self.active_marker_is_midpoint)
@@ -453,7 +466,11 @@ impl ColorPickerMessageHandler {
453466
let old_color = self.old_color();
454467

455468
let hex_value = new_color.map(|c| color_to_hex_optional_alpha(&c)).unwrap_or_else(|| "-".to_string());
456-
let rgb_255 = new_color.map(|c| (c.r() as f64 * 255., c.g() as f64 * 255., c.b() as f64 * 255.));
469+
// RGB readouts display sRGB byte values to the user, so we convert from linear-light to gamma here before quantizing.
470+
let rgb_255 = new_color.map(|c| {
471+
let [r, g, b, _] = c.to_gamma_srgb_channels();
472+
(r as f64 * 255., g as f64 * 255., b as f64 * 255.)
473+
});
457474

458475
// Epsilon comparison since the picker round-trips through HSV
459476
let differs = match (new_color, old_color) {
@@ -470,7 +487,7 @@ impl ColorPickerMessageHandler {
470487

471488
// New/old comparison swatch with swap button
472489
groups.push(LayoutGroup::row(vec![
473-
ColorComparisonInput::new(new_color, old_color)
490+
ColorComparisonInput::new(new_color.map(SRGBA8::from), old_color.map(SRGBA8::from))
474491
.is_none(self.is_none)
475492
.old_is_none(self.old_is_none)
476493
.disabled(self.disabled)
@@ -566,7 +583,10 @@ impl ColorPickerMessageHandler {
566583
.disabled(self.disabled)
567584
.show_none_option(self.allow_none && self.gradient.is_none())
568585
.on_update(|update: &ColorPresetsInputUpdate| match update {
569-
ColorPresetsInputUpdate::Preset(fill_choice) => ColorPickerMessage::PickPreset { preset: fill_choice.clone() }.into(),
586+
ColorPresetsInputUpdate::Preset(fill_choice) => ColorPickerMessage::PickPreset {
587+
preset: FillChoice::from(fill_choice),
588+
}
589+
.into(),
570590
ColorPresetsInputUpdate::EyedropperColorCode(code) => ColorPickerMessage::EyedropperColorCode { code: code.clone() }.into(),
571591
})
572592
.widget_instance(),
@@ -611,33 +631,10 @@ const SATURATION_DESCRIPTION: &str = "The vividness from grayscale to full color
611631
const VALUE_DESCRIPTION: &str = "The brightness from black to full color.";
612632
const ALPHA_DESCRIPTION: &str = "The level of translucency, from transparent (0%) to opaque (100%).";
613633

614-
/// Convert an `rgb(0..1)` triple to `hsv(0..1)`. Mirrors the legacy frontend `colorToHSV`.
615-
fn rgb_to_hsv(red: f64, green: f64, blue: f64) -> [f64; 3] {
616-
let max = red.max(green).max(blue);
617-
let min = red.min(green).min(blue);
618-
let delta = max - min;
619-
620-
let mut hue = if delta == 0. {
621-
0.
622-
} else if max == red {
623-
((green - blue) / delta).rem_euclid(6.)
624-
} else if max == green {
625-
(blue - red) / delta + 2.
626-
} else {
627-
(red - green) / delta + 4.
628-
};
629-
hue = (hue * 60. + 360.).rem_euclid(360.) / 360.;
630-
631-
let saturation = if max == 0. { 0. } else { delta / max };
632-
let value = max;
633-
634-
[hue, saturation, value]
635-
}
636-
637-
/// The popover's background color (the `--color-2-mildblack` design token, `#222`). Used by the comparison swatch's
638-
/// outline computation to brighten the inset border for colors close to this background.
639-
const POPOVER_BACKGROUND: Color = Color::from_rgbaf32_unchecked(0x22 as f32 / 255., 0x22 as f32 / 255., 0x22 as f32 / 255., 1.);
640-
/// The luminance window (in linear-light) within which a color is considered close enough to the popover background
634+
/// The popover's background color as sRGB gamma-encoded channels (the `--color-2-mildblack` design token, `#222`).
635+
/// Used by the comparison swatch's outline computation to brighten the inset border for colors close to this background.
636+
const POPOVER_BACKGROUND_GAMMA_CHANNELS: [f32; 4] = [0x22 as f32 / 255., 0x22 as f32 / 255., 0x22 as f32 / 255., 1.];
637+
/// The luminance window within which a color is considered close enough to the popover background
641638
/// to warrant an outline. Mirrors the `proximityRange` argument the legacy frontend passed to `contrastingOutlineFactor`.
642639
const OUTLINE_PROXIMITY_RANGE: f64 = 0.01;
643640

@@ -646,61 +643,22 @@ const OUTLINE_PROXIMITY_RANGE: f64 = 0.01;
646643
fn contrasting_outline_factor(color: Option<Color>) -> f64 {
647644
let Some(color) = color else { return 0. };
648645

649-
// WCAG-style relative luminance, with alpha composited over white in gamma space
650-
let luminance = |color: Color| {
651-
// TODO: Remove the `.to_linear_srgb()` once we move to correctly treating `Color` as linear.
652-
Color::WHITE
653-
.alpha_blend(Color::from_unassociated_alpha(color.r(), color.g(), color.b(), color.a()))
654-
.to_linear_srgb()
655-
.luminance_srgb() as f64
646+
// WCAG-style relative luminance, with alpha composited over white in sRGB gamma space (matching the perceptual intent of `SRGBA8::contrasting_text_color`).
647+
let luminance_from_gamma_channels = |[r, g, b, a]: [f32; 4]| -> f64 {
648+
let inv_a = 1. - a;
649+
Color::from_gamma_srgb_channels(inv_a + r * a, inv_a + g * a, inv_a + b * a, 1.).luminance_rec_709() as f64
656650
};
657651

658-
let distance = (luminance(POPOVER_BACKGROUND) - luminance(color)).abs().max(0.);
652+
let color_gamma_channels = color.to_gamma_srgb_channels();
653+
let distance = (luminance_from_gamma_channels(POPOVER_BACKGROUND_GAMMA_CHANNELS) - luminance_from_gamma_channels(color_gamma_channels))
654+
.abs()
655+
.max(0.);
659656
let proximity = 1. - (distance / OUTLINE_PROXIMITY_RANGE).min(1.);
660-
let [_, saturation, _] = rgb_to_hsv(color.r() as f64, color.g() as f64, color.b() as f64);
661-
proximity * (1. - saturation)
657+
let [_, saturation, _, _] = color.to_hsva();
658+
proximity * (1. - saturation as f64)
662659
}
663660

664-
/// Format a Color as a `#`-prefixed hex string, including the alpha component only if it's not fully opaque.
661+
/// Format a linear `Color` as a `#`-prefixed hex string, including the alpha component only if it's not fully opaque.
665662
fn color_to_hex_optional_alpha(color: &Color) -> String {
666-
format!(
667-
"#{}",
668-
if color.a() >= 1. {
669-
color.to_rgb_hex_srgb_from_gamma()
670-
} else {
671-
color.to_rgba_hex_srgb_from_gamma()
672-
}
673-
)
674-
}
675-
676-
/// Parse a CSS color string (named color, hex, `rgb(...)`, etc.) into a `Color` using the `color` crate's CSS Color 4 parser.
677-
/// 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:
678-
/// - 1 char `f` → `#fff` (CSS 3-char shorthand)
679-
/// - 2 char `ab` → `#ababab` (repeated to 6 chars)
680-
/// - 4 char `abcd` → `#00abcd` (left-padded with `00`)
681-
/// - 5 char `abcde` → `#0abcde` (left-padded with `0`)
682-
/// - 3, 6, 8 char inputs are passed through with a `#` prefix.
683-
fn parse_css_color(input: &str) -> Option<Color> {
684-
let trimmed = input.trim();
685-
686-
let parsed = color::parse_color(trimmed).ok().or_else(|| {
687-
let bare = trimmed.strip_prefix('#').unwrap_or(trimmed);
688-
if bare.is_empty() || !bare.chars().all(|c| c.is_ascii_hexdigit()) {
689-
return None;
690-
}
691-
let expanded = match bare.len() {
692-
1 => bare.repeat(3),
693-
2 => bare.repeat(3),
694-
4 => format!("00{bare}"),
695-
5 => format!("0{bare}"),
696-
_ => bare.to_string(),
697-
};
698-
let candidate = format!("#{expanded}");
699-
// Avoid retrying the exact same string we just failed to parse.
700-
(candidate != trimmed).then(|| color::parse_color(&candidate).ok()).flatten()
701-
})?;
702-
703-
let srgb: AlphaColor<Srgb> = parsed.to_alpha_color();
704-
let [red, green, blue, alpha] = srgb.components;
705-
Color::from_rgbaf32(red, green, blue, alpha)
663+
SRGBA8::from(*color).to_css_hex()
706664
}

editor/src/messages/frontend/frontend_message.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ use crate::messages::portfolio::utility_types::WorkspacePanelLayout;
1313
use crate::messages::prelude::*;
1414
use crate::messages::tool::tool_messages::eyedropper_tool::PrimarySecondary;
1515
use graph_craft::document::NodeId;
16+
use graphene_std::color::SRGBA8;
1617
use graphene_std::raster::Image;
17-
use graphene_std::raster::color::Color;
1818
use graphene_std::text::Font;
19-
use graphene_std::vector::style::FillChoice;
19+
use graphene_std::vector::style::FillChoiceUI;
2020
use std::path::PathBuf;
2121

2222
#[cfg(not(target_family = "wasm"))]
@@ -167,16 +167,16 @@ pub enum FrontendMessage {
167167
document_id: DocumentId,
168168
},
169169
UpdateGradientStopColorPickerPosition {
170-
color: Color, // TODO: Color (without `none`) -> Color (with `none`)
170+
color: SRGBA8, // TODO: Color (without `none`) -> Color (with `none`)
171171
position: (f64, f64),
172172
},
173-
/// The Rust color picker handler picked a new color/gradient. The frontend `<ColorPicker>` forwards this as its `colorOrGradient` event.
173+
/// The Rust color picker handler picked a new color/gradient. The frontend `<ColorPicker />` forwards this as its `colorOrGradient` event.
174174
ColorPickerColorChanged {
175-
value: FillChoice,
175+
value: FillChoiceUI,
176176
},
177-
/// The Rust color picker handler is starting an undo transaction. The frontend `<ColorPicker>` forwards this as its `startHistoryTransaction` event.
177+
/// The Rust color picker handler is starting an undo transaction. The frontend `<ColorPicker />` forwards this as its `startHistoryTransaction` event.
178178
ColorPickerStartHistoryTransaction,
179-
/// The Rust color picker handler is committing the in-flight undo transaction. The frontend `<ColorPicker>` forwards this as its `commitHistoryTransaction` event.
179+
/// The Rust color picker handler is committing the in-flight undo transaction. The frontend `<ColorPicker />` forwards this as its `commitHistoryTransaction` event.
180180
ColorPickerCommitHistoryTransaction,
181181
UpdateImportsExports {
182182
/// If the primary import is not visible, then it is None.
@@ -241,7 +241,7 @@ pub enum FrontendMessage {
241241
svg: String,
242242
},
243243
UpdateImageData {
244-
image_data: Vec<(u64, Image<Color>)>,
244+
image_data: Vec<(u64, Image<SRGBA8>)>,
245245
},
246246
UpdateDocumentLayerDetails {
247247
data: LayerPanelEntry,

editor/src/messages/frontend/utility_types.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ pub struct DocumentInfo {
1414
pub is_saved: bool,
1515
}
1616

17-
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))]
17+
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints, from_wasm_abi))]
1818
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1919
pub struct PersistedState {
2020
pub documents: Vec<DocumentInfo>,

editor/src/messages/layout/layout_message_handler.rs

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
use crate::messages::input_mapper::utility_types::input_keyboard::KeysGroup;
22
use crate::messages::layout::utility_types::widget_prelude::*;
33
use crate::messages::prelude::*;
4-
use graphene_std::vector::style::FillChoice;
4+
use graphene_std::color::SRGBA8;
5+
use graphene_std::vector::style::FillChoiceUI;
56
use serde_json::Value;
67
use std::collections::HashMap;
78

@@ -179,11 +180,11 @@ impl LayoutMessageHandler {
179180
let callback_message = match action {
180181
WidgetValueAction::Commit => (color_button.on_commit.callback)(&()),
181182
WidgetValueAction::Update => {
182-
let Ok(fill_choice) = serde_json::from_value::<FillChoice>(value) else {
183-
warn!("ColorInput update was not able to be parsed as FillChoice: {color_button:?}");
183+
let Ok(fill_choice_ui) = serde_json::from_value::<FillChoiceUI>(value) else {
184+
warn!("ColorInput update was not able to be parsed as FillChoiceUI: {color_button:?}");
184185
return;
185186
};
186-
color_button.value = fill_choice;
187+
color_button.value = fill_choice_ui;
187188
(color_button.on_update.callback)(color_button)
188189
}
189190
};
@@ -496,25 +497,14 @@ fn populate_computed_display_fields(layout: &mut Layout) {
496497
}
497498
Widget::SpectrumInput(spectrum_input) => {
498499
spectrum_input.track_css = spectrum_input.track.to_css_linear_gradient();
499-
spectrum_input.track_start_css = spectrum_input
500-
.track
501-
.color
502-
.first()
503-
.map(|color| format!("#{}", color.to_rgba_hex_srgb_from_gamma()))
504-
.unwrap_or_else(|| "black".to_string());
505-
spectrum_input.track_end_css = spectrum_input
506-
.track
507-
.color
508-
.last()
509-
.map(|color| format!("#{}", color.to_rgba_hex_srgb_from_gamma()))
510-
.unwrap_or_else(|| "black".to_string());
500+
spectrum_input.track_start_css = spectrum_input.track.color.first().map(|color| color.to_css_hex()).unwrap_or_else(|| "black".to_string());
501+
spectrum_input.track_end_css = spectrum_input.track.color.last().map(|color| color.to_css_hex()).unwrap_or_else(|| "black".to_string());
511502
}
512503
Widget::ColorComparisonInput(comparison) => {
513-
use graphene_std::Color;
514-
let contrasting = |color: Option<Color>| format!("#{}", color.map_or(Color::BLACK, |color| color.contrasting_text_color_from_gamma()).to_rgba_hex_srgb_from_gamma());
515-
comparison.new_color_css = comparison.new_color.map(|color| format!("#{}", color.to_rgba_hex_srgb_from_gamma())).unwrap_or_default();
504+
let contrasting = |color: Option<SRGBA8>| color.map_or(SRGBA8::BLACK, |color| color.contrasting_text_color()).to_css_hex();
505+
comparison.new_color_css = comparison.new_color.map(|color| color.to_css_hex()).unwrap_or_default();
516506
comparison.new_color_contrasting = contrasting(comparison.new_color);
517-
comparison.old_color_css = comparison.old_color.map(|color| format!("#{}", color.to_rgba_hex_srgb_from_gamma())).unwrap_or_default();
507+
comparison.old_color_css = comparison.old_color.map(|color| color.to_css_hex()).unwrap_or_default();
518508
comparison.old_color_contrasting = contrasting(comparison.old_color);
519509
}
520510
_ => {}

0 commit comments

Comments
 (0)