Merge pull request #4534 from dotCooCoo/fix/issue-3161

Fix #2249: macOS converts the canvas from sRGB to the display's profile
This commit is contained in:
BFlat
2026-10-29 15:11:58 +07:00
committed by GitHub
5 changed files with 152 additions and 1 deletions
Generated
+3
View File
@@ -3282,6 +3282,7 @@ dependencies = [
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
]
[[package]]
@@ -6052,8 +6053,10 @@ dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-text",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
"pollster 1.0.1",
"raw-window-handle",
"rfd",
+5 -1
View File
@@ -58,7 +58,7 @@ windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_G
# with default features off). Pinned exactly: review the diff before bumping.
muda = { version = "=0.21.2", default-features = false }
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "objc2-quartz-core", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] }
# Files opened from Finder and the Dock (src/open_documents.rs), pasted bitmaps (src/clipboard.rs) and the control channel's App Nap activity (src/control_server.rs); both already come with winit, muda and rfd.
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSGeometry", "NSNotification", "NSOperation", "NSProcessInfo", "NSString", "NSURL"] }
block2 = "0.6"
@@ -70,6 +70,10 @@ raw-window-handle = "0.6"
# objc2-foundation.
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFString", "CFURL"] }
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontManager"] }
# The windows' Metal layers and the sRGB color space they are tagged with (src/mac_color_space.rs).
# Zlib OR Apache-2.0 OR MIT, already in the build through wgpu with these features.
objc2-quartz-core = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "objc2-core-graphics", "CALayer", "CAMetalLayer"] }
objc2-core-graphics = { version = "0.3", default-features = false, features = ["std", "CGColorSpace"] }
# Waits for the answer of a file dialog shown as a sheet (src/main.rs, #867). MIT OR Apache-2.0,
# already in the build through eframe.
pollster = "1"
+3
View File
@@ -63,6 +63,9 @@ impl eframe::App for App {
let inset = mac_window::center_buttons(frame, ui.ctx().zoom_factor(), bar);
self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET);
}
// macOS converts the frame from sRGB to the display's profile (#2249).
#[cfg(all(target_os = "macos", feature = "wgpu"))]
super::mac_color_space::tag(ui);
file_access::unconfined(|| self.app.ui(ui));
// Preferences are written when confirmed, not only at exit: a session ended any other way
// (the system shutting down, the process ended or crashing) used to lose them (#1747).
+139
View File
@@ -0,0 +1,139 @@
//! macOS: the windows' Metal layers are tagged sRGB, so macOS converts what VectorCraft draws to
//! the display's color profile (#2249, #3161).
//!
//! VectorCraft draws sRGB values. Apple's documentation of `CAMetalLayer.colorspace` says: "When a
//! color space is present, Core Animation performs any necessary color space transformations when
//! compositing this content. The default value is `nil`, indicating that the rendered content
//! isn’t color-matched"
//! (<https://developer.apple.com/documentation/quartzcore/cametallayer/colorspace>). Without a
//! color space, a wide-gamut display shows the sRGB values as its own, so `#FF0000` is drawn as
//! the display's most saturated red, brighter than the same color in an exported PNG.
//!
//! wgpu 30 sets the layer's color space to nil each time it configures the surface: at start, on
//! a resize and when the window moves to a display with another scale (gfx-rs/wgpu#10013). egui
//! configures the surface while it paints a frame, after [`eframe::App::ui`] has run, so [`tag`]
//! adds a paint callback that draws nothing. egui calls it after the configuration and before the
//! frame is presented, and it sets sRGB on every window's Metal layer that has no color space. A
//! layer that already has a color space keeps it.
//!
//! Reading a layer's sublayers has no safe API, hence the scoped `unsafe_code` allowance.
#![allow(unsafe_code)]
use eframe::egui_wgpu::{self, CallbackResources, CallbackTrait, wgpu};
use objc2::MainThreadMarker;
use objc2::rc::Retained;
use objc2::{DowncastTarget, Message};
use objc2_app_kit::NSApplication;
use objc2_core_graphics::{CGColorSpace, kCGColorSpaceSRGB};
use objc2_quartz_core::{CALayer, CAMetalLayer};
/// Adds the callback that tags the windows' Metal layers to the frame `ui` draws: a point in its
/// top-left corner, in the background layer, with nothing drawn.
pub fn tag(ui: &egui::Ui) {
let ctx = ui.ctx();
let rect = egui::Rect::from_min_size(ctx.content_rect().min, egui::vec2(1.0, 1.0));
ctx.layer_painter(egui::LayerId::background()).add(egui_wgpu::Callback::new_paint_callback(rect, TagLayers));
}
struct TagLayers;
impl CallbackTrait for TagLayers {
fn paint(&self, _: egui::PaintCallbackInfo, _: &mut wgpu::RenderPass<'static>, _: &CallbackResources) {
// AppKit's windows and views are read on the main thread only, where eframe paints.
let Some(mtm) = MainThreadMarker::new() else {
return;
};
for window in NSApplication::sharedApplication(mtm).windows() {
if let Some(root) = window.contentView().and_then(|view| view.layer()) {
for layer in metal_layers(&root) {
tag_srgb(&layer);
}
}
}
}
}
/// The Metal layers that draw a view whose layer is `root`: `root` itself, or the sublayers wgpu
/// adds to it when it is a plain layer.
fn metal_layers(root: &CALayer) -> Vec<Retained<CAMetalLayer>> {
if let Some(layer) = downcast(root) {
return vec![layer];
}
// SAFETY: the returned array is a copy; the layers in it are retained while it lives.
let Some(sublayers) = (unsafe { root.sublayers() }) else {
return Vec::new();
};
sublayers.iter().filter_map(|layer| downcast(&layer)).collect()
}
fn downcast<T: DowncastTarget>(layer: &CALayer) -> Option<Retained<T>> {
layer.retain().downcast::<T>().ok()
}
/// Sets sRGB as `layer`'s color space when it has none. Returns whether it was set.
fn tag_srgb(layer: &CAMetalLayer) -> bool {
if layer.colorspace().is_some() {
return false;
}
// SAFETY: a constant that CoreGraphics exports.
let Some(srgb) = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceSRGB })) else {
return false;
};
layer.setColorspace(Some(&srgb));
true
}
#[cfg(test)]
mod tests {
use objc2_core_foundation::CFString;
use objc2_core_graphics::kCGColorSpaceDisplayP3;
use super::*;
fn space_name(layer: &CAMetalLayer) -> Option<String> {
CGColorSpace::name(layer.colorspace().as_deref()).map(|name| name.to_string())
}
fn name(constant: &CFString) -> String {
constant.to_string()
}
/// A layer without a color space is tagged sRGB once; the second call leaves it as it is.
#[test]
fn an_untagged_layer_becomes_srgb() {
let layer = CAMetalLayer::new();
assert_eq!(space_name(&layer), None, "a new layer has no color space");
assert!(tag_srgb(&layer));
assert_eq!(space_name(&layer), Some(name(unsafe { kCGColorSpaceSRGB })));
assert!(!tag_srgb(&layer));
assert_eq!(space_name(&layer), Some(name(unsafe { kCGColorSpaceSRGB })));
}
/// A layer with a color space of its own keeps it.
#[test]
fn a_layer_with_a_color_space_keeps_it() {
let layer = CAMetalLayer::new();
let p3 = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceDisplayP3 })).unwrap();
layer.setColorspace(Some(&p3));
assert!(!tag_srgb(&layer));
assert_eq!(space_name(&layer), Some(name(unsafe { kCGColorSpaceDisplayP3 })));
}
/// The Metal layer is found as the view's layer or as one of its sublayers, and only Metal
/// layers are returned.
#[test]
fn metal_layers_are_found_under_the_view_layer() {
let metal = CAMetalLayer::new();
assert_eq!(metal_layers(&metal), vec![metal.clone()]);
let root = CALayer::new();
assert!(metal_layers(&root).is_empty());
root.addSublayer(&CALayer::new());
root.addSublayer(&metal);
assert_eq!(metal_layers(&root), vec![metal.clone()]);
for layer in metal_layers(&root) {
tag_srgb(&layer);
}
assert_eq!(space_name(&metal), Some(name(unsafe { kCGColorSpaceSRGB })));
}
}
+2
View File
@@ -26,6 +26,8 @@ mod gpu;
#[cfg(target_os = "linux")]
mod linux_stylus;
mod logging;
#[cfg(all(target_os = "macos", feature = "wgpu"))]
mod mac_color_space;
#[cfg(target_os = "macos")]
mod mac_fonts;
#[cfg(target_os = "macos")]