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
+561
View File
@@ -0,0 +1,561 @@
//! TRACES: FR-DSP-8 | FR-PLAT-LIN-2 | NFR-PORT-1
//! What colour the screen in front of the photographer actually is.
//!
//! The display path is colour-managed by encoding the render into the output
//! device's space (FR-DSP-6); that space is a parameter of composition, so
//! getting it right is a question of *asking the platform* rather than of
//! doing anything to the pipeline. This module is the asking.
//!
//! # Why this is a correctness problem and not a polish one
//!
//! FR-DSP-8 says so itself: "on a multi-monitor desktop with differing
//! profiles, showing wrong colours on the second display is a correctness
//! defect, not a polish item". Every other display requirement fails by being
//! slow. This one fails by being *quietly wrong* — a photographer grades a
//! skin tone on a wide-gamut panel, the app encodes sRGB into it, and the
//! result is oversaturated on screen and correct in the file, or the reverse.
//! Nothing about the image looks broken. That is the whole danger.
//!
//! # Acquisition is stated per display server, because it differs entirely
//!
//! - **X11** publishes ICC profiles as root-window properties: `_ICC_PROFILE`
//! for the first output and `_ICC_PROFILE_<n>` for the rest, a convention
//! from the ICC Profiles in X specification that colord, xiccd and
//! `xcalib` all write to. The bytes are a whole ICC profile.
//! - **Wayland** has no such back door — a client cannot read another
//! client's or the compositor's state — and the protocol that replaces it,
//! `wp_color_manager_v1`, is still *staging* upstream. Where the compositor
//! advertises it we ask it; where it does not, there is no mechanism at all
//! and we say so.
//!
//! FR-DSP-8 anticipates exactly that gap and demands "a defined fallback where
//! Wayland provides no profile". The fallback is sRGB, and the requirement's
//! wording — *defined*, not *silent* — is why [`ProfileSource`] carries the
//! reason as data rather than logging it: the About page renders it, so a
//! photographer being shown sRGB because the compositor would not say
//! otherwise can find that out rather than wonder.
//!
//! # Four spaces, and no ICC engine
//!
//! The pipeline can encode into four spaces ([`ColourSpace`]). A real display
//! profile is a measurement of one panel and is none of them. Rather than
//! grow a general ICC transform engine — a much larger piece of work, with a
//! CMM, rendering intents, LUT-based profiles and a per-frame cost to argue
//! about — this reduces the profile to its primaries and picks the nearest of
//! the four, and then *says that it did*. See [`nearest_space`].
//!
//! An approximation the photographer can see beats an exact answer they
//! cannot, and beats a silent approximation by a much larger margin.
use dr_types::colour::Chromaticities;
use dr_types::ColourSpace;
#[cfg(all(unix, not(target_os = "android")))]
mod icc;
#[cfg(all(unix, not(target_os = "android")))]
mod wayland;
#[cfg(all(unix, not(target_os = "android")))]
mod x11;
#[cfg(all(unix, not(target_os = "android")))]
pub use icc::{read_profile, IccSummary};
/// Which display server the session is running on.
///
/// Decided from the environment rather than by trying to connect, because the
/// answer decides *which* connection to attempt: `DISPLAY` is set inside a
/// Wayland session too (Xwayland sets it), so probing X11 first would put
/// every GNOME and KDE user on the X11 path and read an Xwayland root window
/// that no colour manager necessarily writes to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisplayServer {
Wayland,
X11,
/// Neither — a headless test runner, Android, or a build with no display
/// integration. Not an error: it is the case the fallback exists for.
None,
}
impl DisplayServer {
/// What the session says it is.
pub fn detect() -> Self {
// `WAYLAND_DISPLAY` first and unconditionally. See the type comment:
// both variables are set in a Wayland session, and only this order
// gets such a session onto the path its compositor actually owns.
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
Self::Wayland
} else if std::env::var_os("DISPLAY").is_some() {
Self::X11
} else {
Self::None
}
}
pub fn label(self) -> &'static str {
match self {
Self::Wayland => "Wayland",
Self::X11 => "X11",
Self::None => "no display server",
}
}
}
/// Why a display ended up on the sRGB fallback.
///
/// Carried as data because the About page renders it. FR-DSP-8 asks for a
/// *defined* fallback, and a fallback whose reason cannot be shown is
/// indistinguishable from a display that genuinely is sRGB.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FallbackReason {
/// Wayland, and the compositor does not advertise `wp_color_manager_v1`.
/// The common case today and the one FR-DSP-8's wording exists for.
NoWaylandProtocol,
/// The compositor or X server was asked and had nothing to say about this
/// display — no `_ICC_PROFILE` atom, or an image description that failed.
NoProfileForDisplay,
/// Something was found and could not be read. Distinguished from "nothing
/// was there" because it is a bug report rather than a configuration.
Unreadable(String),
/// The connection itself failed, or there is no display server.
NoConnection(String),
}
impl FallbackReason {
/// A phrase for the About page, reading after "sRGB assumed:".
pub fn describe(&self) -> String {
match self {
Self::NoWaylandProtocol => {
"the compositor does not offer colour management".to_string()
}
Self::NoProfileForDisplay => "no profile is assigned to this display".to_string(),
Self::Unreadable(why) => format!("the profile could not be read ({why})"),
Self::NoConnection(why) => format!("no display server ({why})"),
}
}
}
/// How a display's colour was established.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProfileSource {
/// An ICC profile read from an X11 root-window property, named here so a
/// bug report can quote which one.
X11RootProperty(String),
/// The compositor's own answer, over `wp_color_manager_v1`.
WaylandColourManagement,
/// FR-DSP-8's stated fallback.
FallbackSrgb(FallbackReason),
}
impl ProfileSource {
/// Whether this is a profile the platform stated, rather than an assumption.
pub fn is_measured(&self) -> bool {
!matches!(self, Self::FallbackSrgb(_))
}
/// A short phrase naming the acquisition path, for the About page.
pub fn label(&self) -> String {
match self {
Self::X11RootProperty(atom) => format!("X11 {atom}"),
Self::WaylandColourManagement => "Wayland colour management".to_string(),
Self::FallbackSrgb(_) => "fallback".to_string(),
}
}
}
/// The colour of one display, reduced to something the pipeline can encode.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DisplayProfile {
/// The space to compose the canvas into while this display is showing it.
pub space: ColourSpace,
/// Where `space` came from.
pub source: ProfileSource,
/// The profile's own name, where it carried one. A photographer who
/// calibrated this panel recognises the string their calibrator wrote,
/// which is the quickest way to confirm we are reading the right profile.
pub described_as: Option<String>,
/// Whether `space` is the *nearest* of the four rather than a match.
///
/// True for essentially every measured panel, and that is the honest
/// answer rather than a defect: a calibration profile describes one piece
/// of glass and no standard space describes it exactly.
pub approximated: bool,
}
impl DisplayProfile {
/// The sRGB fallback FR-DSP-8 defines, carrying why it was reached.
pub fn fallback(reason: FallbackReason) -> Self {
Self {
space: ColourSpace::Srgb,
source: ProfileSource::FallbackSrgb(reason),
described_as: None,
// Not an approximation of anything. sRGB is being *assumed*, which
// is a different claim and reads differently on the About page.
approximated: false,
}
}
/// One line for the About page: the space, then how we know.
///
/// Written here rather than in the interface because the three cases —
/// measured and exact, measured and approximated, assumed — differ in
/// what they are claiming, and a format string assembled in Slint would
/// lose that distinction the first time someone edited it.
pub fn describe(&self) -> String {
let space = self.space.label();
match &self.source {
ProfileSource::FallbackSrgb(reason) => {
format!("{space} assumed — {}", reason.describe())
}
source if self.approximated => {
let named = self
.described_as
.as_deref()
.map_or_else(String::new, |d| format!("{d}, "));
format!("nearest to {space} ({named}via {})", source.label())
}
source => format!("{space} (via {})", source.label()),
}
}
}
/// A display, as the platform describes it.
#[derive(Debug, Clone, PartialEq)]
pub struct DisplayInfo {
/// The connector or output name — "DP-1", "eDP-1" — where the server gave
/// one, otherwise an index. Shown in About so a two-monitor user can tell
/// which row is which.
pub name: String,
/// Where this display sits in the desktop's coordinate space, in logical
/// pixels, if the server said. `None` on Wayland, which deliberately does
/// not tell a client where anything is; see [`DisplaySurvey::containing`].
pub bounds: Option<Bounds>,
pub profile: DisplayProfile,
}
/// A display's rectangle in the desktop's logical coordinate space.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Bounds {
pub x: i32,
pub y: i32,
pub width: u32,
pub height: u32,
}
impl Bounds {
pub fn contains(&self, x: i32, y: i32) -> bool {
x >= self.x
&& y >= self.y
&& x < self.x.saturating_add_unsigned(self.width)
&& y < self.y.saturating_add_unsigned(self.height)
}
}
/// Every display the session has, and how each was asked.
#[derive(Debug, Clone, PartialEq)]
pub struct DisplaySurvey {
pub server: DisplayServer,
pub displays: Vec<DisplayInfo>,
}
impl DisplaySurvey {
/// Ask the platform. Never fails: a failure *is* the fallback.
///
/// Cheap enough to repeat — an X11 property fetch or a Wayland roundtrip —
/// but not free, so callers hold the result and re-survey on a display
/// change rather than per frame.
pub fn probe() -> Self {
let server = DisplayServer::detect();
#[cfg(all(unix, not(target_os = "android")))]
let displays = match server {
DisplayServer::Wayland => wayland::probe(),
DisplayServer::X11 => x11::probe(),
DisplayServer::None => Err(FallbackReason::NoConnection(
"neither WAYLAND_DISPLAY nor DISPLAY is set".to_string(),
)),
};
// Android draws through the platform compositor and has its own
// wide-gamut path (FR-DSP-6); neither display server exists there, so
// the crates are not even compiled in. sRGB, stated as such.
#[cfg(not(all(unix, not(target_os = "android"))))]
let displays: Result<Vec<DisplayInfo>, FallbackReason> = Err(FallbackReason::NoConnection(
"no display-server integration on this platform".to_string(),
));
let displays = match displays {
// A server that connected but listed nothing is the same situation
// as one that would not connect: there is no display to describe,
// and the canvas still has to be composed into *something*.
Ok(found) if !found.is_empty() => found,
Ok(_) => vec![Self::assumed(FallbackReason::NoProfileForDisplay)],
Err(reason) => vec![Self::assumed(reason)],
};
Self { server, displays }
}
fn assumed(reason: FallbackReason) -> DisplayInfo {
DisplayInfo {
name: "display".to_string(),
bounds: None,
profile: DisplayProfile::fallback(reason),
}
}
/// A survey with no platform in it, for tests and for the headless case.
pub fn assumed_srgb(reason: FallbackReason) -> Self {
Self {
server: DisplayServer::None,
displays: vec![Self::assumed(reason)],
}
}
/// Which display is showing a window whose top-left is at `(x, y)` in
/// logical desktop coordinates, as an index into [`Self::displays`].
///
/// **The point is the window's centre, and the caller computes it.** A
/// window straddling two monitors is showing more of itself on one of
/// them, and its centre is the cheapest statement of which.
///
/// # Wayland returns 0, and that is the protocol's answer, not a bug
///
/// A Wayland client is not told where its window is — there is no
/// equivalent of `XGetGeometry` against the root, deliberately, and
/// `xdg_toplevel` carries no position. The protocol's own answer to "which
/// output am I on" is `wl_surface.enter`, or better,
/// `wp_color_management_surface_feedback_v1`, which hands the client the
/// preferred image description for *its surface* and re-sends it when the
/// window moves. Both require the application's `wl_surface`, which Slint
/// owns and does not expose, so this falls back to the first display and
/// the About page shows every display's profile rather than one.
///
/// The consequence is worth stating plainly: on a multi-monitor Wayland
/// desktop the canvas is composed for the *first* output. That is a
/// smaller error than composing for the wrong space entirely — the
/// profiles are read correctly, and a single-monitor session, which is
/// most of them, is exactly right — and the fix is a Slint surface handle
/// rather than anything in this module.
pub fn containing(&self, x: i32, y: i32) -> usize {
self.displays
.iter()
.position(|d| d.bounds.is_some_and(|b| b.contains(x, y)))
// Off every known rectangle: a window dragged past the edge of the
// desktop, or a server that gave no geometry. The first display is
// the primary one on both servers we speak to.
.unwrap_or(0)
}
/// The profile for a display index, tolerating an index that has gone
/// stale because a monitor was unplugged between survey and render.
pub fn profile(&self, index: usize) -> &DisplayProfile {
self.displays
.get(index)
.or_else(|| self.displays.first())
.map(|d| &d.profile)
.expect("probe always yields at least one display")
}
}
/// The nearest of the four spaces the pipeline can encode, and whether it is
/// near enough to call a match.
///
/// # Compared as colorants, not as chromaticity pairs
///
/// The obvious comparison — eight xy numbers against eight xy numbers — is
/// wrong, and wrong in a way that would pass a casual test. A profile's
/// colorants have already been chromatically adapted to the D50 connection
/// space; a colour space's *published* chromaticities have not. Comparing the
/// two directly makes every D65 space look like it has the wrong white point
/// and pushes matches towards ProPhoto, the only D50 member of the four.
///
/// So both sides go through [`Chromaticities::to_pcs_xyz`] and are compared as
/// the nine numbers a matrix/TRC profile would carry. That is the same
/// reduction `dr-export` writes into a file, which means a profile this
/// pipeline produced round-trips back to the space it was produced from — the
/// test at the bottom of this module.
///
/// # Primaries and not the tone curve
///
/// The four spaces are distinguished by gamut first: sRGB and Display P3
/// share a transfer function entirely. A measured panel's TRC is a tabulated
/// curve fitted to a target gamma and matching it against three analytic
/// curves would decide nothing the primaries have not already decided. The
/// transfer function comes along with whichever space the primaries choose,
/// which is the right pairing — encoding P3 primaries through an Adobe RGB
/// gamma would be a space nobody has.
pub fn nearest_space(measured: &Chromaticities) -> (ColourSpace, bool) {
nearest_by_colorants(&measured.to_pcs_xyz())
}
/// [`nearest_space`], entered from the nine numbers directly.
///
/// The two acquisition paths arrive with different halves of the same thing:
/// an ICC profile carries D50-adapted colorants and nothing else, while
/// Wayland's `primaries` event carries xy chromaticities and no matrix. Both
/// reduce to this, so there is one metric and not two that can drift apart.
pub fn nearest_by_colorants(want: &[f32; 9]) -> (ColourSpace, bool) {
let (space, distance) = ColourSpace::ALL
.into_iter()
.map(|candidate| {
let have = candidate.to_pcs_xyz();
let d: f32 = want
.iter()
.zip(have.iter())
.map(|(a, b)| (a - b) * (a - b))
.sum();
(candidate, d)
})
// `f32` and no NaN: every input is a finite colorant matrix, so
// `total_cmp` here is a formality that avoids `partial_cmp().unwrap()`.
.min_by(|a, b| a.1.total_cmp(&b.1))
.expect("ColourSpace::ALL is not empty");
// The threshold is a sum of nine squared differences over numbers of order
// one, so 1e-6 is roughly "every colorant agrees to three decimal places"
// — tight enough that a genuinely different gamut never passes, loose
// enough that a profile written from these same numbers and read back
// through s15Fixed16 rounding does. It is not a perceptual threshold and
// is not trying to be: this decides whether to *say* "approximated", and
// saying it when in doubt is the honest direction to err.
(space, distance < 1e-6)
}
#[cfg(test)]
mod tests {
use super::*;
fn chromaticities_of(space: ColourSpace) -> Chromaticities {
space.chromaticities()
}
#[test]
fn each_known_space_recognises_itself_exactly() {
for space in ColourSpace::ALL {
let (found, exact) = nearest_space(&chromaticities_of(space));
assert_eq!(found, space, "{} matched the wrong space", space.label());
assert!(exact, "{} was not recognised as itself", space.label());
}
}
#[test]
fn a_panel_between_two_gamuts_is_reported_as_an_approximation() {
// A plausible measured wide-gamut panel: near P3's red and green, not
// on them. The requirement is not that it picks P3 — it is that it
// picks *something* and admits it is not exact.
let measured = Chromaticities {
red: [0.6790, 0.3190],
green: [0.2680, 0.6880],
blue: [0.1490, 0.0570],
white: [0.3127, 0.3290],
};
let (found, exact) = nearest_space(&measured);
assert_eq!(found, ColourSpace::DisplayP3);
assert!(!exact, "a measured panel must not claim to be a standard");
}
#[test]
fn a_d50_wide_gamut_panel_does_not_collapse_onto_srgb() {
// The failure this guards: comparing raw xy pairs rather than adapted
// colorants makes white-point differences dominate, and everything
// lands on whichever space shares its illuminant. ProPhoto's gamut is
// unmistakable, so if the metric is right this cannot be anything else.
let (found, _) = nearest_space(&chromaticities_of(ColourSpace::ProPhoto));
assert_eq!(found, ColourSpace::ProPhoto);
}
#[test]
fn adobe_rgb_and_srgb_are_told_apart_despite_sharing_two_primaries() {
// Adobe RGB (1998) differs from sRGB in the green corner alone. A
// metric summing over all three colorants must still separate them,
// or a wide-gamut photo monitor is driven as sRGB.
let (found, exact) = nearest_space(&chromaticities_of(ColourSpace::AdobeRgb));
assert_eq!(found, ColourSpace::AdobeRgb);
assert!(exact);
}
#[test]
fn the_fallback_says_srgb_and_says_why() {
let profile = DisplayProfile::fallback(FallbackReason::NoWaylandProtocol);
assert_eq!(profile.space, ColourSpace::Srgb);
assert!(!profile.source.is_measured());
let described = profile.describe();
assert!(described.contains("sRGB"), "{described}");
assert!(described.contains("assumed"), "{described}");
// The specific reason has to survive into the string the About page
// renders, or the fallback is defined in the code and invisible on
// screen — which is the half of FR-DSP-8 that is easy to skip.
assert!(described.contains("colour management"), "{described}");
}
#[test]
fn an_approximated_profile_says_nearest_rather_than_claiming_the_space() {
let profile = DisplayProfile {
space: ColourSpace::DisplayP3,
source: ProfileSource::X11RootProperty("_ICC_PROFILE".to_string()),
described_as: Some("Studio Display calibrated".to_string()),
approximated: true,
};
let described = profile.describe();
assert!(described.contains("nearest to Display P3"), "{described}");
assert!(
described.contains("Studio Display calibrated"),
"{described}"
);
}
#[test]
fn probing_a_platform_that_cannot_answer_still_yields_a_display() {
// Whatever happens, composition needs a space. The one thing this
// module may never do is hand back nothing.
let survey = DisplaySurvey::probe();
assert!(!survey.displays.is_empty());
assert_eq!(survey.profile(99).space, survey.displays[0].profile.space);
}
#[test]
fn a_window_is_located_on_the_display_its_centre_falls_in() {
let survey = DisplaySurvey {
server: DisplayServer::X11,
displays: vec![
DisplayInfo {
name: "DP-1".to_string(),
bounds: Some(Bounds {
x: 0,
y: 0,
width: 2560,
height: 1440,
}),
profile: DisplayProfile::fallback(FallbackReason::NoProfileForDisplay),
},
DisplayInfo {
name: "DP-2".to_string(),
bounds: Some(Bounds {
x: 2560,
y: 0,
width: 1920,
height: 1080,
}),
profile: DisplayProfile {
space: ColourSpace::AdobeRgb,
source: ProfileSource::X11RootProperty("_ICC_PROFILE_1".to_string()),
described_as: None,
approximated: true,
},
},
],
};
assert_eq!(survey.containing(100, 100), 0);
assert_eq!(survey.containing(3000, 500), 1);
// The seam: the first pixel of the second monitor belongs to it, and
// the last pixel of the first does not. An inclusive upper bound here
// would flip the transform one pixel early on every drag across.
assert_eq!(survey.containing(2559, 0), 0);
assert_eq!(survey.containing(2560, 0), 1);
// Dragged off the desktop entirely — the primary, not a panic.
assert_eq!(survey.containing(-4000, 0), 0);
// And the point of the whole exercise: the two displays yield
// different output spaces.
assert_eq!(survey.profile(0).space, ColourSpace::Srgb);
assert_eq!(survey.profile(1).space, ColourSpace::AdobeRgb);
}
}