Ask the platform what colour the screen actually is

FR-DSP-8's acquisition half. `dr_plat::display` surveys the session's
displays and reduces each one's profile to an output space the pipeline
can encode into, stating the mechanism per display server as
FR-PLAT-LIN-2 requires:

  - X11 reads the `_ICC_PROFILE` / `_ICC_PROFILE_<n>` root-window
    properties, enumerating and numbering the outputs through RandR,
    which also yields the rectangles a window move is measured against.
  - Wayland binds `wp_color_manager_v1` and asks each `wl_output` for
    its image description, accepting either an ICC profile on a file
    descriptor or primaries stated as chromaticities.
  - Where neither answers, sRGB is assumed and the reason travels with
    it as data rather than into a log, so the About page can say which
    path the session is on.

A display profile is a measurement of one panel and is none of the four
spaces the pipeline knows. Rather than grow an ICC engine, the profile
is reduced to D50-adapted colorants and matched against the four; a
match that is merely nearest is marked as such and shown as such.

Verified on this machine: mutter 50 advertises the colour-management
global and reports eDP-1 as sRGB, and the same session forced onto X11
enumerates the output through RandR and correctly finds no atom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 18:20:28 +02:00
co-authored by Claude Opus 5
parent 725f7bf77f
commit e4875498ca
10 changed files with 1561 additions and 2 deletions
+161
View File
@@ -0,0 +1,161 @@
//! TRACES: FR-DSP-8 | FR-PLAT-LIN-2
//! Acquisition on X11: ICC profiles as root-window properties.
//!
//! The mechanism is the ICC Profiles in X specification, and it predates
//! everything else in this module by two decades. A colour manager — colord
//! under GNOME, `xiccd`, `xcalib`, or the desktop's own — loads the profile
//! for each output and publishes its bytes on the *root window* as a property:
//! `_ICC_PROFILE` for the first output, `_ICC_PROFILE_1`, `_ICC_PROFILE_2` and
//! so on for the rest, numbered by the output's position in RandR's list.
//!
//! Any client can read them, which is exactly why this works and exactly why
//! [`super::icc::read_profile`] validates so carefully: a root-window property
//! is untyped shared state that anything on the display may write.
//!
//! # Geometry comes from RandR, and it is what makes a move detectable
//!
//! X11 will also say where each output sits in the desktop's coordinate space,
//! and will tell a client where its own window is. Those two facts together
//! are the whole of FR-DSP-8's "updates when the window moves between
//! displays" on this display server — the window's centre falls in one
//! rectangle or another, and the answer changes as it is dragged. Wayland
//! gives neither, which is why `wayland.rs` says what it says.
use x11rb::connection::Connection;
use x11rb::protocol::randr::ConnectionExt as RandrExt;
use x11rb::protocol::xproto::{AtomEnum, ConnectionExt as XExt};
use super::{
nearest_by_colorants, Bounds, DisplayInfo, DisplayProfile, FallbackReason, ProfileSource,
};
/// Every output the X server has, with whatever profile is published for it.
pub fn probe() -> Result<Vec<DisplayInfo>, FallbackReason> {
let (conn, screen_num) =
x11rb::connect(None).map_err(|e| FallbackReason::NoConnection(format!("X11: {e}")))?;
let root = conn
.setup()
.roots
.get(screen_num)
.ok_or_else(|| FallbackReason::NoConnection("X11: no screen".to_string()))?
.root;
// RandR describes the outputs. Its absence is survivable: a server without
// it still has a root window with `_ICC_PROFILE` on it, so fall through to
// a single unnamed display carrying the first property rather than giving
// up on colour management entirely.
let outputs = enumerate(&conn, root).unwrap_or_default();
let outputs = if outputs.is_empty() {
vec![(String::from("display"), None)]
} else {
outputs
};
Ok(outputs
.into_iter()
.enumerate()
.map(|(index, (name, bounds))| DisplayInfo {
name,
bounds,
profile: profile_for(&conn, root, index),
})
.collect())
}
/// An output's connector name and the rectangle it occupies, where RandR
/// would say. Named so that the order of the two is fixed in one place.
type Output = (String, Option<Bounds>);
/// The connected outputs, in RandR order, with their desktop rectangles.
///
/// Order is load-bearing rather than cosmetic: it is what numbers the
/// `_ICC_PROFILE_<n>` properties, so reordering this list would hand every
/// monitor its neighbour's profile.
fn enumerate(conn: &impl Connection, root: u32) -> Result<Vec<Output>, Box<dyn std::error::Error>> {
let resources = conn.randr_get_screen_resources_current(root)?.reply()?;
let stamp = resources.config_timestamp;
let mut found = Vec::new();
for output in resources.outputs {
let Ok(info) = conn.randr_get_output_info(output, stamp)?.reply() else {
continue;
};
// `crtc == 0` is an output with no CRTC driving it: a port with
// nothing plugged in, or a disabled monitor. It occupies no part of
// the desktop and takes no place in the `_ICC_PROFILE_<n>` numbering.
if info.crtc == 0 {
continue;
}
let name = String::from_utf8_lossy(&info.name).to_string();
let bounds = conn
.randr_get_crtc_info(info.crtc, stamp)
.ok()
.and_then(|c| c.reply().ok())
.map(|c| Bounds {
x: i32::from(c.x),
y: i32::from(c.y),
width: u32::from(c.width),
height: u32::from(c.height),
});
found.push((name, bounds));
}
Ok(found)
}
/// The property this output's profile would be published on, read and reduced.
fn profile_for(conn: &impl Connection, root: u32, index: usize) -> DisplayProfile {
// The specification's numbering: the first output's property carries no
// suffix. Writing `_ICC_PROFILE_0` instead reads nothing on every desktop.
let name = if index == 0 {
"_ICC_PROFILE".to_string()
} else {
format!("_ICC_PROFILE_{index}")
};
let bytes = match fetch(conn, root, &name) {
Ok(Some(bytes)) => bytes,
// No property. The overwhelmingly common case on a stock desktop with
// no calibration installed, and not a fault of any kind.
Ok(None) => return DisplayProfile::fallback(FallbackReason::NoProfileForDisplay),
Err(e) => return DisplayProfile::fallback(FallbackReason::Unreadable(format!("X11: {e}"))),
};
match super::icc::read_profile(&bytes) {
Ok(summary) => {
let (space, exact) = nearest_by_colorants(&summary.colorants);
DisplayProfile {
space,
source: ProfileSource::X11RootProperty(name),
described_as: summary.description,
approximated: !exact,
}
}
Err(why) => DisplayProfile::fallback(FallbackReason::Unreadable(why)),
}
}
/// Read a root-window property whole.
///
/// The length is asked for in 32-bit units, which is X11's unit for this call
/// and a trap worth naming: a display profile is a few kilobytes and a request
/// stated in bytes would truncate it into a shape `read_profile` then rejects
/// as corrupt. `u32::MAX / 4` asks for all of it.
fn fetch(
conn: &impl Connection,
root: u32,
name: &str,
) -> Result<Option<Vec<u8>>, Box<dyn std::error::Error>> {
let atom = conn.intern_atom(true, name.as_bytes())?.reply()?.atom;
// `only_if_exists` above: an atom that no one has interned is a property
// no one has set, and interning it ourselves would litter the server with
// atoms for the monitors this desktop does not have.
if atom == 0 {
return Ok(None);
}
let reply = conn
.get_property(false, root, atom, AtomEnum::CARDINAL, 0, u32::MAX / 4)?
.reply()?;
if reply.value.is_empty() {
return Ok(None);
}
Ok(Some(reply.value))
}