platform: declare the window content colorspace to the OS

WindowContentColorspace (primaries x transfer) + set_content_colorspace:
Wayland rebuilds the color-management-v1 image description per spec
(P3/BT.2020 primaries, PQ/HLG transfer with luminances), macOS tags the
Metal layer with the matching CGColorSpace (incl. BT.2100 PQ/HLG),
Windows records it (ACM only honors sRGB). Also: warn when the wgpu
surface fallback would pick an sRGB-suffixed format (double encoding),
share the surface-params uniform across 256 slots (multiple viewers per
frame), and cfg-gate the viewer's GPU-texture source for macOS/Windows.
This commit is contained in:
2026-08-29 00:24:00 +08:00
parent 4ed8b2bf76
commit 41dac8f33e
11 changed files with 607 additions and 44 deletions
+86
View File
@@ -743,6 +743,27 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle {
fn set_client_inset(&self, _inset: Pixels) {}
fn gpu_specs(&self) -> Option<GpuSpecs>;
/// Declare who maps this window's pixels to the physical display (the
/// single-mapping rule of color management). `OsManaged` tags the
/// content as a defined colorimetric space (sRGB) so the OS performs
/// the display mapping; `SelfManaged` tags the layer with the display's
/// own color space so the OS passes the app's already-mapped pixels
/// through. Platforms without a per-layer declaration (Windows, X11)
/// ignore it; Wayland declares the content space through
/// color-management-v1 at the surface level, not here.
fn set_layer_color_management(&mut self, _mode: LayerColorManagement) {}
/// Declare the colorimetric space this window's content is encoded in
/// (the "content is what" half of the color-management declaration; see
/// [`WindowContentColorspace`]). Combined with
/// [`Self::set_layer_color_management`] the platform knows both what the
/// content is and who maps it, so the one display mapping is fully
/// determined. macOS retags the Metal layer; Wayland rebuilds the
/// color-management-v1 image description; platforms without a per-layer
/// declaration (Windows, X11) record it without acting on it — the OS
/// maps sRGB-declared content by default.
fn set_content_colorspace(&mut self, _colorspace: WindowContentColorspace) {}
/// Returns the GPU context for this window's renderer.
/// The returned `Box` contains `(Arc<wgpu::Device>, Arc<wgpu::Queue>)`.
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
@@ -1711,6 +1732,71 @@ pub enum WindowAppearance {
VibrantDark,
}
/// Who performs the final color mapping from the window's rendered pixels
/// to the physical display, declared to the platform layer so the mapping
/// happens exactly once (the single-mapping rule of color management).
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub enum LayerColorManagement {
/// The OS maps the window's content to the display. The content is a
/// defined colorimetric space (sRGB unless the platform declares
/// otherwise), so the layer is tagged accordingly and the OS performs
/// the one and only display mapping.
#[default]
OsManaged,
/// The application has already mapped its content to the display (it
/// applied the display ICC transform itself). The layer is tagged with
/// the display's own color space so the OS passes the pixels through
/// instead of correcting them a second time.
SelfManaged,
}
/// The colorimetric space the window's content is encoded in, declared to
/// the platform layer alongside [`LayerColorManagement`] (who maps) so the
/// one display mapping is fully determined: the content space is named
/// here, the display space is known to the OS, and whichever side maps
/// does so exactly once.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub struct WindowContentColorspace {
/// The primaries (chromaticities) the content is encoded with.
pub primaries: ContentPrimaries,
/// The transfer function (encoding curve) the content is encoded with.
pub transfer: ContentTransfer,
}
/// The primaries (chromaticity coordinates) the window's content is
/// encoded with.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub enum ContentPrimaries {
/// sRGB primaries — identical to BT.709, the classic SDR
/// broadcast/video primaries.
#[default]
Srgb,
/// Display P3 primaries (the wider, DCI-P3-derived space of Apple and
/// modern displays).
DisplayP3,
/// BT.2020 primaries (the ultra-wide space of UHD/HDR video).
Bt2020,
}
/// The transfer function (encoding curve) the window's content is encoded
/// with.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub enum ContentTransfer {
/// The sRGB transfer curve (the standard SDR curve of displays and
/// web/video content).
#[default]
Srgb,
/// Pure gamma 2.2 (the classic video SDR curve; near-identical to sRGB
/// in practice).
Gamma22,
/// SMPTE ST 2084 (PQ), the perceptually-quantized HDR curve (HDR10).
Pq,
/// Hybrid Log-Gamma, the broadcast HDR curve that carries SDR
/// compatibility in its lower range.
Hlg,
}
/// The appearance of the background of the window itself, when there is
/// no content or the content is transparent.
#[derive(Copy, Clone, Debug, Default, PartialEq)]
+25 -3
View File
@@ -7,7 +7,8 @@ use crate::{
DispatchActionListener, DispatchNodeId, DispatchTree, DisplayId, Edges, Effect, Entity,
EntityId, EventEmitter, FileDropEvent, Filter, FilterBoundary, FontId, Global, GlobalElementId,
GlyphId, GpuSpecs, Hsla, InputHandler, IsZero, KeyBinding, KeyContext, KeyDownEvent, KeyEvent,
Keystroke, KeystrokeEvent, LayoutId, Lerp, LineLayoutIndex, Modifiers, ModifiersChangedEvent,
Keystroke, KeystrokeEvent, LayerColorManagement, LayoutId, Lerp, LineLayoutIndex, Modifiers,
ModifiersChangedEvent,
MonochromeSprite, MouseButton, MouseEvent, MouseMoveEvent, MouseUpEvent, Path, Pixels,
PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point,
PolychromeSprite, Priority, PromptButton, PromptLevel, Quad, Render, RenderGlyphParams,
@@ -17,8 +18,9 @@ use crate::{
SystemWindowTab, SystemWindowTabController, TabStopMap, TaffyLayoutEngine, Task,
TextRenderingMode, TextStyle, TextStyleRefinement, ThermalState, TransformationMatrix,
Transition, TransitionState, Underline, UnderlineStyle, WindowAppearance,
WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, WindowOptions,
WindowParams, WindowTextSystem, point, prelude::*, px, rems, size, transparent_black,
WindowBackgroundAppearance, WindowBounds, WindowContentColorspace, WindowControls,
WindowDecorations, WindowOptions, WindowParams, WindowTextSystem, point, prelude::*, px, rems,
size, transparent_black,
};
use anyhow::{Context as _, Result, anyhow};
use collections::{FxHashMap, FxHashSet};
@@ -5546,6 +5548,26 @@ impl Window {
self.platform_window.gpu_device_lost()
}
/// Declare who maps this window's pixels to the physical display (the
/// single-mapping rule of color management). Call this when the app's
/// display color-management mode changes; on macOS it retags the Metal
/// layer so ColorSync either maps the content (OS-managed) or passes
/// the app's already-mapped pixels through (self-managed). Other
/// platforms treat it as a no-op or surface-level declaration.
pub fn set_layer_color_management(&mut self, mode: LayerColorManagement) {
self.platform_window.set_layer_color_management(mode);
}
/// Declare the colorimetric space this window's content is encoded in
/// (see [`WindowContentColorspace`]) — the "content is what" half of the
/// color-management declaration, matched with the "who maps" half in
/// [`Self::set_layer_color_management`]. Call this when the app's output
/// colorspace changes; platforms with a per-layer declaration (macOS,
/// Wayland) act on it immediately, others record it for later.
pub fn set_content_colorspace(&mut self, colorspace: WindowContentColorspace) {
self.platform_window.set_content_colorspace(colorspace);
}
/// Perform titlebar double-click action.
/// This is macOS specific.
pub fn titlebar_double_click(&self) {
@@ -67,6 +67,10 @@ use wayland_protocols::{
wp::fractional_scale::v1::client::{wp_fractional_scale_manager_v1, wp_fractional_scale_v1},
xdg::dialog::v1::client::xdg_dialog_v1::XdgDialogV1,
};
use wayland_protocols::wp::color_management::v1::client::{
wp_color_management_surface_v1, wp_color_manager_v1, wp_image_description_creator_params_v1,
wp_image_description_v1,
};
use wayland_protocols_plasma::blur::client::{org_kde_kwin_blur, org_kde_kwin_blur_manager};
use wayland_protocols_wlr::layer_shell::v1::client::{zwlr_layer_shell_v1, zwlr_layer_surface_v1};
use xkbcommon::xkb::ffi::XKB_KEYMAP_FORMAT_TEXT_V1;
@@ -132,6 +136,11 @@ pub struct Globals {
pub gesture_manager: Option<zwp_pointer_gestures_v1::ZwpPointerGesturesV1>,
pub dialog: Option<xdg_wm_dialog_v1::XdgWmDialogV1>,
pub system_bell: Option<xdg_system_bell_v1::XdgSystemBellV1>,
/// color-management-v1: declares each surface's content colorspace to
/// the compositor (the OS side of the single-mapping rule on Wayland).
/// `None` on compositors without the extension — they assume sRGB,
/// which is exactly what the app presents, so behavior is unchanged.
pub color_manager: Option<wp_color_manager_v1::WpColorManagerV1>,
pub executor: ForegroundExecutor,
}
@@ -174,6 +183,10 @@ impl Globals {
gesture_manager: globals.bind(&qh, 1..=3, ()).ok(),
dialog: globals.bind(&qh, dialog_v..=dialog_v, ()).ok(),
system_bell: globals.bind(&qh, 1..=1, ()).ok(),
// The compositor advertises the version it implements; negotiate
// up to 2 (the sRGB declaration only uses requests available at
// version 1, so a v1 compositor works identically).
color_manager: globals.bind(&qh, 1..=2, ()).ok(),
executor,
qh,
}
@@ -1176,6 +1189,14 @@ delegate_noop!(WaylandClientStatePtr: ignore zwp_text_input_manager_v3::ZwpTextI
delegate_noop!(WaylandClientStatePtr: ignore org_kde_kwin_blur::OrgKdeKwinBlur);
delegate_noop!(WaylandClientStatePtr: ignore wp_viewporter::WpViewporter);
delegate_noop!(WaylandClientStatePtr: ignore wp_viewport::WpViewport);
// color-management-v1: the app only ever SETS image descriptions (the
// surface content colorspace declaration) — the manager's capability
// events and the surface/creator lifecycle events need no handling, so
// ignore them. The image-description `ready`/`failed` events DO get a real
// Dispatch below.
delegate_noop!(WaylandClientStatePtr: ignore wp_color_manager_v1::WpColorManagerV1);
delegate_noop!(WaylandClientStatePtr: ignore wp_color_management_surface_v1::WpColorManagementSurfaceV1);
delegate_noop!(WaylandClientStatePtr: ignore wp_image_description_creator_params_v1::WpImageDescriptionCreatorParamsV1);
impl Dispatch<WlCallback, ObjectId> for WaylandClientStatePtr {
fn event(
@@ -2307,6 +2328,36 @@ impl Dispatch<wp_fractional_scale_v1::WpFractionalScaleV1, ObjectId> for Wayland
}
}
impl Dispatch<wp_image_description_v1::WpImageDescriptionV1, ObjectId> for WaylandClientStatePtr {
fn event(
this: &mut Self,
image_description: &wp_image_description_v1::WpImageDescriptionV1,
event: <wp_image_description_v1::WpImageDescriptionV1 as Proxy>::Event,
surface_id: &ObjectId,
_: &Connection,
_: &QueueHandle<Self>,
) {
use wp_image_description_v1::Event;
match event {
// The description is usable now — attach it to the surface.
// (Interface v1 sends `Ready`, v2+ sends `Ready2`.)
Event::Ready { .. } | Event::Ready2 { .. } => {
let client = this.get_client();
let mut state = client.borrow_mut();
let Some(window) = get_window(&mut state, surface_id) else {
return;
};
drop(state);
window.apply_color_description(image_description.clone());
}
Event::Failed { msg, .. } => {
log::warn!("wayland color-management image description rejected: {msg}");
}
_ => {}
}
}
}
impl Dispatch<zxdg_toplevel_decoration_v1::ZxdgToplevelDecorationV1, ObjectId>
for WaylandClientStatePtr
{
+132 -7
View File
@@ -11,6 +11,7 @@ use futures::channel::oneshot::Receiver;
use raw_window_handle as rwh;
use wayland_backend::client::ObjectId;
use wayland_client::QueueHandle;
use wayland_client::WEnum;
use wayland_client::{
Proxy,
@@ -24,18 +25,21 @@ use wayland_protocols::{
wp::fractional_scale::v1::client::wp_fractional_scale_v1,
xdg::dialog::v1::client::xdg_dialog_v1::XdgDialogV1,
};
use wayland_protocols::wp::color_management::v1::client::{
wp_color_management_surface_v1, wp_color_manager_v1, wp_image_description_v1,
};
use wayland_protocols_plasma::blur::client::org_kde_kwin_blur;
use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_surface_v1;
use crate::linux::wayland::{display::WaylandDisplay, serial::SerialKind};
use crate::linux::{Globals, Output, WaylandClientStatePtr, get_window};
use gpui::{
AnyWindowHandle, Bounds, Capslock, Decorations, DevicePixels, GpuSpecs, Modifiers, Pixels,
PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point,
PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, Scene, Size, Tiling,
WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls,
WindowDecorations, WindowKind, WindowParams, layer_shell::LayerShellNotSupportedError, px,
size,
AnyWindowHandle, Bounds, Capslock, ContentPrimaries, ContentTransfer, Decorations,
DevicePixels, GpuSpecs, Modifiers, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput,
PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions,
ResizeEdge, Scene, Size, Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds,
WindowContentColorspace, WindowControlArea, WindowControls, WindowDecorations, WindowKind,
WindowParams, layer_shell::LayerShellNotSupportedError, px, size,
};
use gpui_wgpu::{CompositorGpuHint, WgpuRenderer, WgpuSurfaceConfig, wgpu};
@@ -99,6 +103,19 @@ pub struct WaylandWindowState {
appearance: WindowAppearance,
blur: Option<org_kde_kwin_blur::OrgKdeKwinBlur>,
viewport: Option<wp_viewport::WpViewport>,
/// color-management-v1 handle for this surface (the OS side of the
/// single-mapping rule); `None` when the compositor lacks the extension.
color_surface: Option<wp_color_management_surface_v1::WpColorManagementSurfaceV1>,
/// The image description while it waits for the compositor's `ready`
/// event (illegal to use before then); dropped once applied. Rebuilt
/// with a new description when the content colorspace changes.
pending_color_description: Option<wp_image_description_v1::WpImageDescriptionV1>,
/// The colorimetric space this surface's content is encoded in, as
/// declared to the compositor via color-management-v1 (the "content is
/// what" half of the single-mapping rule; the compositor maps it to the
/// display — the "who maps" half is always the OS on Wayland). Kept so
/// a later `set_content_colorspace` can rebuild the description.
colorspace: WindowContentColorspace,
outputs: HashMap<ObjectId, Output>,
display: Option<(ObjectId, Output)>,
globals: Globals,
@@ -325,6 +342,49 @@ pub struct WaylandWindowStatePtr {
callbacks: Rc<RefCell<Callbacks>>,
}
/// Creates a parametric color-management-v1 image description declaring
/// `colorspace` for the surface whose id is `surface_id` (the description
/// is looked up by that id in the client's dispatch, so the window's
/// surface id is used as the object data, mirroring window creation).
///
/// The description is not usable until the compositor replies with its
/// `ready`/`ready2` event — the caller keeps it pending (or queues it for
/// `apply_color_description`) until then.
fn create_color_description(
color_manager: &wp_color_manager_v1::WpColorManagerV1,
qh: &QueueHandle<WaylandClientStatePtr>,
surface_id: ObjectId,
colorspace: WindowContentColorspace,
) -> wp_image_description_v1::WpImageDescriptionV1 {
let params = color_manager.create_parametric_creator(qh, ());
let primaries = match colorspace.primaries {
ContentPrimaries::Srgb => wp_color_manager_v1::Primaries::Srgb,
ContentPrimaries::DisplayP3 => wp_color_manager_v1::Primaries::DisplayP3,
ContentPrimaries::Bt2020 => wp_color_manager_v1::Primaries::Bt2020,
};
params.set_primaries_named(primaries);
// Protocol v1 has no sRGB transfer-function value a client can use
// (`srgb` was only added in v2, deprecated right after): for sRGB-class
// SDR content the compositor-side recommendation is to declare gamma
// 2.2, which is near-identical in practice — so both `Srgb` and
// `Gamma22` content map to it.
let transfer = match colorspace.transfer {
ContentTransfer::Srgb | ContentTransfer::Gamma22 => {
wp_color_manager_v1::TransferFunction::Gamma22
}
ContentTransfer::Pq => wp_color_manager_v1::TransferFunction::St2084Pq,
ContentTransfer::Hlg => wp_color_manager_v1::TransferFunction::Hlg,
};
params.set_tf_named(transfer);
// The protocol's default luminances are SDR (0.280 cd/m²); an HDR
// transfer function implies a wider volume, so pin it explicitly: PQ
// 1.0 peaks at 10000 cd/m² with an 80 cd/m² reference white.
if matches!(colorspace.transfer, ContentTransfer::Pq | ContentTransfer::Hlg) {
params.set_luminances(0, 10000, 80);
}
params.create(qh, surface_id)
}
impl WaylandWindowState {
pub(crate) fn new(
handle: AnyWindowHandle,
@@ -373,13 +433,35 @@ impl WaylandWindowState {
xdg_state.toplevel.set_title(title.to_string());
}
// Set max window size based on the GPU's maximum texture dimension.
// This prevents the window from being resized larger than what the GPU can render.
// This prevents the window from being resized larger than the GPU can render.
let max_texture_size = renderer.max_texture_size() as i32;
xdg_state
.toplevel
.set_max_size(max_texture_size, max_texture_size);
}
// color-management-v1: declare the surface content's colorspace so a
// color-managed compositor maps it to the display (the OS side of
// the single-mapping rule). The image description object only
// becomes usable on its `ready` event — handled in
// `apply_color_description` — so keep both objects on the state.
// `colorspace` defaults to sRGB content (Srgb + Srgb), which is
// what the app presented before this declaration existed.
let colorspace = WindowContentColorspace::default();
let (color_surface, pending_color_description) = match globals.color_manager.as_ref() {
Some(color_manager) => {
let cm_surface = color_manager.get_surface(&surface, &globals.qh, ());
let desc = create_color_description(
color_manager,
&globals.qh,
surface.id(),
colorspace,
);
(Some(cm_surface), Some(desc))
}
None => (None, None),
};
Ok(Self {
surface_state,
acknowledged_first_configure: false,
@@ -389,6 +471,9 @@ impl WaylandWindowState {
app_id: None,
blur: None,
viewport,
color_surface,
pending_color_description,
colorspace,
globals,
outputs: HashMap::default(),
display: None,
@@ -583,6 +668,25 @@ impl WaylandWindowStatePtr {
self.state.borrow().surface.clone()
}
/// Applies a ready image description to this window's surface
/// (color-management-v1), replacing whatever description was applied
/// before. Called from the image-description `ready` dispatch for both
/// the initial declaration and rebuilds after a colorspace change; a
/// no-op when the compositor lacks the extension. The pending hold on
/// the description is released — `set_image_description` has copy
/// semantics, so the object may be dropped right after.
pub fn apply_color_description(&self, desc: wp_image_description_v1::WpImageDescriptionV1) {
let mut state = self.state.borrow_mut();
state.pending_color_description = None;
let Some(color_surface) = state.color_surface.as_ref() else {
return;
};
color_surface.set_image_description(&desc, wp_color_manager_v1::RenderIntent::Perceptual);
let surface = state.surface.clone();
drop(state);
surface.commit();
}
pub fn toplevel(&self) -> Option<xdg_toplevel::XdgToplevel> {
self.state.borrow().surface_state.toplevel().cloned()
}
@@ -1309,6 +1413,27 @@ impl PlatformWindow for WaylandWindow {
update_window(state);
}
fn set_content_colorspace(&mut self, colorspace: WindowContentColorspace) {
let mut state = self.borrow_mut();
state.colorspace = colorspace;
// Rebuild the color-management-v1 image description for the new
// space; the compositor replies with `ready`, which the client
// dispatch routes back into `apply_color_description` (the pending
// slot replaced here is the same one that call consumes). No-op
// when the compositor lacks the extension — it assumes sRGB, which
// the recorded value only overrides on compositors that care.
let Some(color_manager) = state.globals.color_manager.as_ref() else {
return;
};
let desc = create_color_description(
color_manager,
&state.globals.qh,
state.surface.id(),
colorspace,
);
state.pending_color_description = Some(desc);
}
fn background_appearance(&self) -> WindowBackgroundAppearance {
self.borrow().background_appearance
}
+95 -9
View File
@@ -1,10 +1,10 @@
// GPUI macOS platform - GPUI is licensed under the Apache License, Version 2.0
// (see the gpui submodule's license).
//! The main display's colorspace as a raw `CGColorSpaceRef`, for the
//! color-management coordination in `metal_renderer` (when the app
//! self-manages the display transform, the CAMetalLayer is tagged with the
//! display's colorspace so ColorSync passes the pixels through).
//! The colorspace tagging of the window's Metal layer, for the
//! color-management coordination in `metal_renderer` (the single-mapping
//! rule: whoever maps the pixels to the display — the OS or the app — the
//! layer is tagged so the other side does not map them again).
//!
//! This lives in its own module because the `metal_renderer` build script
//! scans `metal_renderer.rs` for FFI declarations and forwards them into
@@ -12,10 +12,20 @@
use std::ffi::c_void;
use gpui::{ContentPrimaries, ContentTransfer, WindowContentColorspace};
#[link(name = "CoreGraphics", kind = "framework")]
unsafe extern "C" {
fn CGMainDisplayID() -> u32;
fn CGDisplayCopyColorSpace(display: u32) -> *mut c_void;
fn CGColorSpaceCreateWithName(name: *const c_void) -> *mut c_void;
static kCGColorSpaceSRGB: *const c_void;
static kCGColorSpaceDisplayP3: *const c_void;
static kCGColorSpaceITUR_2020: *const c_void;
// The BT.2100 constants exist only on macOS 10.15+ (the deployment
// target); on older systems they resolve to nil and creation fails.
static kCGColorSpaceITUR_2100_PQ: *const c_void;
static kCGColorSpaceITUR_2100_HLG: *const c_void;
}
#[link(name = "CoreFoundation", kind = "framework")]
@@ -23,13 +33,89 @@ unsafe extern "C" {
fn CFRelease(obj: *const c_void);
}
/// The main display's colorspace as a raw pointer (caller's
/// responsibility to keep it alive for the msg_send call; released here
/// after the layer call returns — the layer retains it). `None` when the
/// display has no colorspace (headless).
/// Run `f` with the colorspace of `display_id` as a raw `CGColorSpaceRef`
/// (caller's responsibility to keep it alive for the duration of the call;
/// released here afterwards — receivers such as the Metal layer retain it).
/// Returns whether `f` ran; `false` when the display has no colorspace
/// (headless), in which case the caller keeps whatever tag the layer had.
pub fn with_display_colorspace(display_id: u32, f: impl FnOnce(*mut c_void)) -> bool {
unsafe {
let space = CGDisplayCopyColorSpace(display_id);
if space.is_null() {
return false;
}
f(space);
CFRelease(space);
true
}
}
/// The main display's colorspace (kept for callers without a specific
/// screen context).
pub fn with_main_display_colorspace(f: impl FnOnce(*mut c_void)) {
unsafe {
let space = CGDisplayCopyColorSpace(CGMainDisplayID());
let display = CGMainDisplayID();
with_display_colorspace(display, f);
}
}
/// The main display's `CGDirectDisplayID` (fallback when a window has no
/// screen to ask for its own).
pub fn main_display_id() -> u32 {
unsafe { CGMainDisplayID() }
}
/// Run `f` with the sRGB colorspace as a raw `CGColorSpaceRef` (the
/// declaration that the layer's content is colorimetric sRGB, so
/// ColorSync performs the display mapping). Skips `f` when the colorspace
/// cannot be created.
pub fn with_srgb_colorspace(f: impl FnOnce(*mut c_void)) {
unsafe {
let space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
if space.is_null() {
return;
}
f(space);
CFRelease(space);
}
}
/// Run `f` with the `CGColorSpaceRef` matching the window's declared
/// content colorspace — the tag says what the layer's pixels *are*, so
/// ColorSync performs the one mapping to the display (the single-mapping
/// rule in this module's docs). Falls back toward sRGB and skips `f` only
/// if nothing could be created.
pub fn with_content_colorspace(spec: WindowContentColorspace, f: impl FnOnce(*mut c_void)) {
unsafe {
let name = match spec.transfer {
ContentTransfer::Srgb | ContentTransfer::Gamma22 => match spec.primaries {
ContentPrimaries::Srgb => kCGColorSpaceSRGB,
ContentPrimaries::DisplayP3 => kCGColorSpaceDisplayP3,
ContentPrimaries::Bt2020 => kCGColorSpaceITUR_2020,
},
// The ITU-R BT.2100 constants exist only on macOS 10.15+.
ContentTransfer::Pq => kCGColorSpaceITUR_2100_PQ,
ContentTransfer::Hlg => kCGColorSpaceITUR_2100_HLG,
};
let space = CGColorSpaceCreateWithName(name);
if !space.is_null() {
f(space);
CFRelease(space);
return;
}
// Creation failed (a BT.2100 declaration on macOS < 10.15, or an
// unusual named-space miss). Fall back — HLG to PQ, everything else
// to sRGB — so the layer keeps a valid colorspace declaration.
log::warn!(
"no CGColorSpace for content colorspace {:?}, falling back",
spec
);
let fallback = if matches!(spec.transfer, ContentTransfer::Hlg) {
kCGColorSpaceITUR_2100_PQ
} else {
kCGColorSpaceSRGB
};
let space = CGColorSpaceCreateWithName(fallback);
if space.is_null() {
return;
}
+55 -12
View File
@@ -251,23 +251,66 @@ impl MetalRenderer {
];
}
// Color management coordination: when the app self-manages the
// display transform (OAK_MACOS_LAYER_COLORSPACE=display, set at
// startup when display-ICC color management is active), tag the
// layer with the DISPLAY's colorspace so ColorSync's mapping
// becomes a pass-through — otherwise the OS would re-correct our
// already-corrected pixels (double correction).
if std::env::var_os("OAK_MACOS_LAYER_COLORSPACE").as_deref()
== Some(std::ffi::OsStr::new("display"))
{
crate::display_colorspace::with_main_display_colorspace(|space| unsafe {
// Color management coordination (the single-mapping rule): by default
// the content is colorimetric sRGB and ColorSync maps it to the
// display, so tag the layer with the declared content colorspace
// explicitly rather than relying on the implicit default. When the
// app self-manages the display transform it re-tags the layer with
// the display's own colorspace via `set_layer_color_management`,
// making ColorSync's mapping a pass-through (otherwise the OS would
// re-correct the already-corrected pixels — a double correction).
crate::display_colorspace::with_content_colorspace(
gpui::WindowContentColorspace::default(),
|space| unsafe {
let _: () = msg_send![&*layer, setColorspace: space];
});
}
},
);
Self::new_internal(device, Some(layer), !transparent, instance_buffer_pool)
}
/// Tag the window's Metal layer for who maps the pixels to the display.
///
/// `OsManaged` tags the layer with `content_colorspace` — the declaration
/// of what the pixels are, so ColorSync performs the one display mapping.
/// `SelfManaged` tags the layer with `display_id`'s colorspace (the
/// content declaration is then informational: the app already mapped the
/// pixels) so ColorSync passes them through; a display with no colorspace
/// (headless) leaves the current tag and logs, since a stale pass-through
/// tag would make the OS re-map the app's already-mapped pixels. No-op
/// for the headless renderer (no layer).
pub fn set_layer_color_management(
&self,
mode: gpui::LayerColorManagement,
display_id: u32,
content_colorspace: gpui::WindowContentColorspace,
) {
let Some(layer) = self.layer.as_ref() else {
return;
};
match mode {
gpui::LayerColorManagement::OsManaged => {
crate::display_colorspace::with_content_colorspace(content_colorspace, |space| {
unsafe {
let _: () = msg_send![&*layer, setColorspace: space];
}
});
}
gpui::LayerColorManagement::SelfManaged => {
let tagged =
crate::display_colorspace::with_display_colorspace(display_id, |space| unsafe {
let _: () = msg_send![&*layer, setColorspace: space];
});
if !tagged {
log::error!(
"display {display_id} has no colorspace; leaving the layer's previous \
tag ColorSync may re-map the app's already-mapped pixels"
);
}
}
}
}
/// Creates a new headless MetalRenderer for offscreen rendering without a window.
///
/// This renderer can render scenes to images without requiring a CAMetalLayer,
+56
View File
@@ -473,6 +473,12 @@ struct MacWindowState {
cursor_visible: Arc<AtomicBool>,
display_link: Option<DisplayLink>,
renderer: renderer::Renderer,
/// Who maps this window's pixels to the display (retagged on screen
/// changes so the self-managed pass-through follows the window).
layer_color_management: gpui::LayerColorManagement,
/// The colorspace the window's content is declared to be in (the tag
/// ColorSync maps from when the OS manages the display transform).
content_colorspace: gpui::WindowContentColorspace,
request_frame_callback: Option<Box<dyn FnMut(RequestFrameOptions)>>,
event_callback: Option<Box<dyn FnMut(PlatformInput) -> gpui::DispatchEventResult>>,
activate_callback: Option<Box<dyn FnMut(bool)>>,
@@ -800,6 +806,8 @@ impl MacWindow {
bounds.size.map(|pixels| pixels.as_f32()),
false,
),
layer_color_management: gpui::LayerColorManagement::default(),
content_colorspace: gpui::WindowContentColorspace::default(),
request_frame_callback: None,
event_callback: None,
activate_callback: None,
@@ -1647,6 +1655,27 @@ impl PlatformWindow for MacWindow {
None
}
fn set_layer_color_management(&mut self, mode: gpui::LayerColorManagement) {
let mut lock = self.0.lock();
lock.layer_color_management = mode;
let display_id = current_display_id(lock.native_window);
lock.renderer
.set_layer_color_management(mode, display_id, lock.content_colorspace);
}
fn set_content_colorspace(&mut self, colorspace: gpui::WindowContentColorspace) {
let mut lock = self.0.lock();
lock.content_colorspace = colorspace;
// The layer tag declares the content's colorspace only when the OS
// performs the display mapping; when the app self-manages it, the tag
// is the display's colorspace regardless of the content declaration.
if lock.layer_color_management == gpui::LayerColorManagement::OsManaged {
let display_id = current_display_id(lock.native_window);
lock.renderer
.set_layer_color_management(lock.layer_color_management, display_id, colorspace);
}
}
fn update_ime_position(&self, _bounds: Bounds<Pixels>) {
let executor = self.0.lock().foreground_executor.clone();
executor
@@ -2363,10 +2392,37 @@ extern "C" fn window_did_change_screen(this: &Object, _: Sel, _: id) {
let window_state = unsafe { get_window_state(this) };
let mut lock = window_state.as_ref().lock();
lock.start_display_link();
// The self-managed pass-through tag carries the display's colorspace;
// re-tag so it follows the window to the new screen (OS-managed is
// sRGB everywhere, but retagging is cheap and keeps one code path).
let mode = lock.layer_color_management;
let display_id = current_display_id(lock.native_window);
lock.renderer
.set_layer_color_management(mode, display_id, lock.content_colorspace);
drop(lock);
update_window_scale_factor(&window_state);
}
/// The `CGDirectDisplayID` of the screen the window is currently on,
/// read from the screen's device description (`NSScreenNumber`). Falls
/// back to the main display when the window has no screen (minimized) or
/// the description lacks the number.
fn current_display_id(native_window: id) -> CGDirectDisplayID {
unsafe {
let screen: id = msg_send![native_window, screen];
if !screen.is_null() {
let device_description: id = msg_send![screen, deviceDescription];
let key: id = ns_string("NSScreenNumber");
let screen_number: id = msg_send![device_description, objectForKey: key];
if !screen_number.is_null() {
let number: NSUInteger = msg_send![screen_number, unsignedIntegerValue];
return number as CGDirectDisplayID;
}
}
}
crate::display_colorspace::main_display_id()
}
extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id) {
let window_state = unsafe { get_window_state(this) };
let lock = window_state.lock();
+55 -13
View File
@@ -139,7 +139,9 @@ fn preferred_surface_formats() -> &'static [wgpu::TextureFormat] {
/// Picks the swapchain format for a surface: the first preferred format
/// the surface supports, falling back to the first non-sRGB format the
/// surface offers, then to whatever it offers first.
/// surface offers, then to whatever it offers first (an sRGB format there
/// is a last resort — the surface format is also the shader output format,
/// so the hardware would encode the shader's already-sRGB values again).
fn select_surface_format(
preferred: &[wgpu::TextureFormat],
supported: &[wgpu::TextureFormat],
@@ -149,7 +151,16 @@ fn select_surface_format(
.find(|f| supported.contains(f))
.copied()
.or_else(|| supported.iter().find(|f| !f.is_srgb()).copied())
.or_else(|| supported.first().copied())
.or_else(|| {
let format = supported.first().copied();
if let Some(format) = format.filter(|f| f.is_srgb()) {
log::warn!(
"surface offers only the sRGB format {format:?}; the format linearizes \
shader output, so sRGB-encoded content is encoded once more (double encoding)"
);
}
format
})
}
pub struct WgpuSurfaceConfig {
@@ -205,8 +216,12 @@ struct WgpuResources {
bind_group_layouts: WgpuBindGroupLayouts,
atlas_sampler: wgpu::Sampler,
surface_sampler: wgpu::Sampler,
#[allow(dead_code)]
surface_uniform_buffer: wgpu::Buffer,
/// One reused uniform buffer holding [`SurfaceParams`] for every painted surface in a frame,
/// each at a distinct (alignment-strided) offset — same slotting scheme as
/// [`Self::blur_params_buffer`], for the same last-write-at-submit reason.
surface_params_buffer: wgpu::Buffer,
/// Stride between [`SurfaceParams`] slots in `surface_params_buffer`.
surface_params_stride: u64,
/// One reused uniform buffer holding [`BlurParams`] for every blur pass in a frame, each at a
/// distinct (alignment-strided) offset. Avoids allocating a buffer per pass; distinct offsets
/// mean `write_buffer`'s last-write-at-submit semantics don't clobber earlier passes.
@@ -264,6 +279,14 @@ const MAX_FILTER_DEPTH: usize = 2;
/// Each frame uses 4 passes per backdrop/group plus one blit; 256 covers dozens of filters.
const BLUR_PARAMS_SLOTS: u64 = 256;
/// Number of [`SurfaceParams`] slots in the shared surface-params buffer (one per painted surface
/// per frame). A frame paints one surface per open viewer (source + program = 2 today), so 256
/// slots leave wide headroom. Distinct (alignment-strided) offsets are what keep each surface's
/// `write_buffer` from clobbering its neighbours' bounds under last-write-at-submit semantics —
/// a single shared slot collapsed every surface onto the last-written bounds and blacked out all
/// but one viewer.
const SURFACE_PARAMS_SLOTS: u64 = 256;
pub struct WgpuRenderer {
/// Shared GPU context for device recovery coordination (unused on WASM).
#[allow(dead_code)]
@@ -421,6 +444,12 @@ impl WgpuRenderer {
context.adapter.get_info().name
)
})?;
log::info!(
"Surface adapter={:?} backend={:?} picked format={:?}",
context.adapter.get_info().name,
context.adapter.get_info().backend,
surface_format
);
let pick_alpha_mode =
|preferences: &[wgpu::CompositeAlphaMode]| -> anyhow::Result<wgpu::CompositeAlphaMode> {
@@ -514,14 +543,18 @@ impl WgpuRenderer {
..Default::default()
});
let surface_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("surface_uniform_buffer"),
size: std::mem::size_of::<SurfaceParams>() as u64,
let uniform_alignment = device.limits().min_uniform_buffer_offset_alignment as u64;
// Shared surface-params buffer: SURFACE_PARAMS_SLOTS slots, one per painted surface per
// frame, each one alignment stride apart (see SURFACE_PARAMS_SLOTS).
let surface_params_stride =
(std::mem::size_of::<SurfaceParams>() as u64).next_multiple_of(uniform_alignment);
let surface_params_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("surface_params_buffer"),
size: surface_params_stride * SURFACE_PARAMS_SLOTS,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let uniform_alignment = device.limits().min_uniform_buffer_offset_alignment as u64;
// Shared blur-params buffer: BLUR_PARAMS_SLOTS slots, each one alignment stride apart.
let blur_params_stride =
(std::mem::size_of::<BlurParams>() as u64).next_multiple_of(uniform_alignment);
@@ -617,7 +650,8 @@ impl WgpuRenderer {
bind_group_layouts,
atlas_sampler,
surface_sampler,
surface_uniform_buffer,
surface_params_buffer,
surface_params_stride,
blur_params_buffer,
globals_buffer,
globals_bind_group,
@@ -1879,7 +1913,8 @@ impl WgpuRenderer {
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn draw_surfaces(&self, surfaces: &[PaintSurface], pass: &mut wgpu::RenderPass<'_>) -> bool {
let resources = self.resources();
for surface in surfaces {
let params_size = std::mem::size_of::<SurfaceParams>() as u64;
for (slot, surface) in surfaces.iter().enumerate() {
let Some(wgpu_texture) = surface.texture.downcast_ref::<wgpu::Texture>() else {
continue;
};
@@ -1891,9 +1926,12 @@ impl WgpuRenderer {
content_mask: surface.content_mask.bounds.into(),
};
// Each surface writes its own alignment-strided slot (see SURFACE_PARAMS_SLOTS);
// a shared slot would leave every surface with the last-written bounds.
let offset = (slot as u64 % SURFACE_PARAMS_SLOTS) * resources.surface_params_stride;
resources.queue.write_buffer(
&resources.surface_uniform_buffer,
0,
&resources.surface_params_buffer,
offset,
bytemuck::bytes_of(&params),
);
@@ -1905,7 +1943,11 @@ impl WgpuRenderer {
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: resources.surface_uniform_buffer.as_entire_binding(),
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &resources.surface_params_buffer,
offset,
size: std::num::NonZeroU64::new(params_size),
}),
},
wgpu::BindGroupEntry {
binding: 1,
+7
View File
@@ -90,6 +90,7 @@ pub fn register_gpu_frame(
/// Look up the GPU frame registered for `image_id`, if any. Does not remove
/// the entry: the same image is reused on every cache hit.
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn take_gpu_frame(image_id: usize) -> Option<GpuFrameEntry> {
GPU_FRAMES
.lock()
@@ -451,12 +452,18 @@ impl<C: PlaybackClock> ViewerWidget<C> {
self.cpu_image = frame.clone();
self.frame_source = match frame {
Some(image) => {
// The 10-bit GPU-texture upgrade is only available where
// [`SurfaceSource::Texture`] exists (linux/freebsd); elsewhere the
// BGRA8 CPU frame is used as-is.
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
let source = take_gpu_frame(image.id.0).map(|entry| {
ViewerFrameSource::Surface(SurfaceSource::Texture {
texture: entry.texture,
size: entry.size,
})
});
#[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
let source: Option<ViewerFrameSource> = None;
Some(source.unwrap_or(ViewerFrameSource::CpuFrame(image)))
}
None => None,
@@ -1206,6 +1206,13 @@ impl DirectXResources {
height,
)?
};
// sRGB is declared unconditionally: with Auto Color Management off
// the declaration is lazy (DXGI keeps the legacy default, which ACM
// also reads as sRGB), and with ACM on the app's policy is always
// OS-managed — the OS performs the one display mapping — so the
// sRGB declaration is exactly right either way. See
// `declare_srgb_swap_chain`.
declare_srgb_swap_chain(&swap_chain);
let (
render_target,
@@ -1750,6 +1757,29 @@ fn create_swap_chain(
Ok(swap_chain)
}
/// Declare the swap chain's content as sRGB (`SetColorSpace1`).
///
/// The renderer always presents sRGB-encoded pixels; without an explicit
/// declaration DXGI assumes the legacy "RGB studio G22 none P709" default,
/// which Windows 11 Auto Color Management also treats as sRGB — but making
/// the declaration explicit removes the reliance on the implicit default and
/// is the single-mapping contract the app honors: the OS (DWM/ACM) performs
/// the one display mapping, and the app must not also apply the display ICC.
/// Best-effort: older drivers without `IDXGISwapChain3` keep the default.
fn declare_srgb_swap_chain(swap_chain: &IDXGISwapChain1) {
match swap_chain.cast::<IDXGISwapChain3>() {
Ok(swap_chain3) => unsafe {
if let Err(error) = swap_chain3.SetColorSpace1(DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709)
{
log::warn!("SetColorSpace1(sRGB) failed: {error}");
}
},
Err(error) => {
log::warn!("IDXGISwapChain3 unavailable, swap chain color space left implicit: {error}");
}
}
}
#[inline]
fn create_resources(
devices: &DirectXRendererDevices,
+15
View File
@@ -53,6 +53,11 @@ pub struct WindowsWindowState {
pub border_offset: WindowBorderOffset,
pub appearance: Cell<WindowAppearance>,
pub background_appearance: Cell<WindowBackgroundAppearance>,
/// The colorspace the window's content is declared to be in. Windows
/// Auto Color Management maps a swap chain only when it is declared
/// sRGB; non-sRGB declarations are handled by the app itself, so this
/// is informational here (see `set_content_colorspace`).
pub content_colorspace: Cell<WindowContentColorspace>,
pub scale_factor: Cell<f32>,
pub restore_from_minimized: Cell<Option<Box<dyn FnMut(RequestFrameOptions)>>>,
@@ -175,6 +180,7 @@ impl WindowsWindowState {
border_offset,
appearance: Cell::new(appearance),
background_appearance: Cell::new(WindowBackgroundAppearance::Opaque),
content_colorspace: Cell::new(WindowContentColorspace::default()),
scale_factor: Cell::new(scale_factor),
restore_from_minimized: Cell::new(restore_from_minimized),
min_size,
@@ -1091,6 +1097,15 @@ impl PlatformWindow for WindowsWindow {
self.state.renderer.borrow().gpu_specs().log_err()
}
fn set_content_colorspace(&mut self, colorspace: WindowContentColorspace) {
// Informational on Windows: Auto Color Management maps a swap chain
// only when it is declared sRGB (`declare_srgb_swap_chain`), and the
// declaration is fixed at creation — non-sRGB content is mapped by
// the app's own pipeline, which reads this declaration. Nothing to
// re-tag at runtime.
self.state.content_colorspace.set(colorspace);
}
fn update_ime_position(&self, bounds: Bounds<Pixels>) {
let scale_factor = self.state.scale_factor.get();
let caret_position = POINT {