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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
//! TRACES: FR-DSP-8
|
||||
//! Reading just enough of an ICC profile to say which of four spaces it is.
|
||||
//!
|
||||
//! # Deliberately not a profile parser
|
||||
//!
|
||||
//! ICC.1 is a large specification, and a complete reader of it is the front
|
||||
//! half of a colour management module: LUT-based transforms, rendering
|
||||
//! intents, per-intent tag sets, v2 and v4 divergence. None of that helps
|
||||
//! here, because the pipeline does not *apply* a profile — it encodes into one
|
||||
//! of four spaces it already knows the numbers for (`dr_types::colour`). The
|
||||
//! only question a display profile has to answer is which of the four it is
|
||||
//! nearest to, and three tags answer it.
|
||||
//!
|
||||
//! So this reads the tag table, pulls the three colorants and the description,
|
||||
//! and refuses everything else with a reason the About page can show. A
|
||||
//! profile it refuses is not a failure of the photographer's setup and must
|
||||
//! not read like one; it means "this panel is described in a way we do not
|
||||
//! approximate", and sRGB is assumed as FR-DSP-8 defines.
|
||||
//!
|
||||
//! # What it does read
|
||||
//!
|
||||
//! `rXYZ`, `gXYZ`, `bXYZ` — the three colorant tags of a matrix/TRC profile,
|
||||
//! which are the space's primaries already adapted to the D50 connection
|
||||
//! space. That is the same reduction `ColourSpace::to_pcs_xyz` produces, which
|
||||
//! is what makes the comparison in [`super::nearest_by_colorants`] an
|
||||
//! apples-to-apples one, and what makes a profile this project *wrote*
|
||||
//! round-trip back to the space it was written from.
|
||||
//!
|
||||
//! `desc` (v2) or `mluc` (v4) for the profile's own name, which exists only to
|
||||
//! be shown: a photographer recognises the string their calibrator wrote and
|
||||
//! can tell at a glance whether we are reading the profile they think we are.
|
||||
|
||||
/// What a display profile was reduced to.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct IccSummary {
|
||||
/// The three colorant columns, row-major, in the D50 connection space.
|
||||
pub colorants: [f32; 9],
|
||||
/// The profile's own description, where it carried a readable one.
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// The ICC header's fixed size. Everything before the tag count.
|
||||
const HEADER: usize = 128;
|
||||
|
||||
/// Reduce a profile to the parts that decide an output space.
|
||||
///
|
||||
/// `Err` carries a phrase for [`super::FallbackReason::Unreadable`], so it is
|
||||
/// written to read after "the profile could not be read (…)" on the About page
|
||||
/// rather than as a developer's error string.
|
||||
pub fn read_profile(bytes: &[u8]) -> Result<IccSummary, String> {
|
||||
if bytes.len() < HEADER + 4 {
|
||||
return Err("it is too short to be a profile".to_string());
|
||||
}
|
||||
// Offset 36 is `acsp`, the profile file signature. Checked because the X11
|
||||
// property is a byte array with no type discipline at all: anything can
|
||||
// write anything to a root window, and a profile-shaped blob that is not
|
||||
// one would otherwise be read as colorants of arbitrary magnitude.
|
||||
if &bytes[36..40] != b"acsp" {
|
||||
return Err("it does not carry the ICC file signature".to_string());
|
||||
}
|
||||
// The header's own length field. A profile whose declared size exceeds the
|
||||
// property is truncated — X11 properties have a fetch length, and reading
|
||||
// colorants out of a half-transferred profile would be silently wrong.
|
||||
let declared = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize;
|
||||
if declared > bytes.len() {
|
||||
return Err(format!(
|
||||
"it declares {declared} bytes and only {} arrived",
|
||||
bytes.len()
|
||||
));
|
||||
}
|
||||
|
||||
let count = be_u32(bytes, HEADER)? as usize;
|
||||
// 12 bytes per tag table entry. The bound is not paranoia: `count` comes
|
||||
// from the profile, and a corrupt one multiplied out is how a reader ends
|
||||
// up indexing megabytes past the end.
|
||||
let table_end = HEADER + 4 + count.checked_mul(12).ok_or("its tag table is absurd")?;
|
||||
if table_end > bytes.len() {
|
||||
return Err("its tag table runs past the end of the profile".to_string());
|
||||
}
|
||||
|
||||
let mut tags: Vec<([u8; 4], usize, usize)> = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let at = HEADER + 4 + i * 12;
|
||||
let sig = [bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]];
|
||||
let offset = be_u32(bytes, at + 4)? as usize;
|
||||
let size = be_u32(bytes, at + 8)? as usize;
|
||||
// Silently skipping a tag that does not fit rather than rejecting the
|
||||
// whole profile: the three we want may all be well-formed while some
|
||||
// fourth tag we will never look at is not.
|
||||
if offset
|
||||
.checked_add(size)
|
||||
.is_some_and(|end| end <= bytes.len())
|
||||
{
|
||||
tags.push((sig, offset, size));
|
||||
}
|
||||
}
|
||||
|
||||
let find = |want: &[u8; 4]| {
|
||||
tags.iter()
|
||||
.find(|(sig, _, _)| sig == want)
|
||||
.map(|&(_, offset, size)| &bytes[offset..offset + size])
|
||||
};
|
||||
|
||||
let (Some(r), Some(g), Some(b)) = (find(b"rXYZ"), find(b"gXYZ"), find(b"bXYZ")) else {
|
||||
// The honest description of a LUT-based profile, which is what a
|
||||
// hardware calibrator often produces. It describes the panel more
|
||||
// accurately than a matrix could, and approximating it would need the
|
||||
// CMM this module exists to avoid. Named as a shape rather than as an
|
||||
// error, because nothing is wrong with such a profile.
|
||||
return Err("it is not a matrix/TRC profile".to_string());
|
||||
};
|
||||
let r = xyz_tag(r)?;
|
||||
let g = xyz_tag(g)?;
|
||||
let b = xyz_tag(b)?;
|
||||
|
||||
Ok(IccSummary {
|
||||
// Row-major, columns R/G/B — the layout `to_pcs_xyz` produces, so the
|
||||
// two can be subtracted entry by entry. Transposing one of them is the
|
||||
// single most plausible bug in this file, which is why the round-trip
|
||||
// test asserts a written-then-read profile lands back on its own space
|
||||
// rather than merely on *some* space.
|
||||
colorants: [r[0], g[0], b[0], r[1], g[1], b[1], r[2], g[2], b[2]],
|
||||
description: find(b"desc").and_then(text_tag),
|
||||
})
|
||||
}
|
||||
|
||||
/// An `XYZType` tag: signature, four reserved bytes, then s15Fixed16 triples.
|
||||
///
|
||||
/// Only the first triple is read. A colorant tag carries exactly one; the
|
||||
/// array form exists for other tags that share the type.
|
||||
fn xyz_tag(data: &[u8]) -> Result<[f32; 3], String> {
|
||||
if data.len() < 20 || &data[0..4] != b"XYZ " {
|
||||
return Err("a colorant tag is not an XYZ value".to_string());
|
||||
}
|
||||
Ok([
|
||||
s15_fixed16(be_i32(data, 8)?),
|
||||
s15_fixed16(be_i32(data, 12)?),
|
||||
s15_fixed16(be_i32(data, 16)?),
|
||||
])
|
||||
}
|
||||
|
||||
/// A profile's description, from either of the two types that carry one.
|
||||
///
|
||||
/// Returns `None` rather than an error throughout: the name is decoration. A
|
||||
/// profile with unreadable text still has perfectly good colorants, and
|
||||
/// refusing it over a string would put a correctly-described display on the
|
||||
/// fallback for a cosmetic reason.
|
||||
fn text_tag(data: &[u8]) -> Option<String> {
|
||||
match data.get(0..4)? {
|
||||
// ICC v2 `textDescriptionType`: signature, reserved, an ASCII byte
|
||||
// count, then that many bytes with a trailing NUL included in the
|
||||
// count.
|
||||
b"desc" => {
|
||||
let count = be_u32(data, 8).ok()? as usize;
|
||||
let text = data.get(12..12 + count)?;
|
||||
let text = text.split(|&b| b == 0).next().unwrap_or(text);
|
||||
Some(String::from_utf8_lossy(text).trim().to_string()).filter(|s| !s.is_empty())
|
||||
}
|
||||
// ICC v4 `multiLocalizedUnicodeType`: UTF-16BE records. The first
|
||||
// record is taken rather than the one matching the user's locale — a
|
||||
// profile name is an identifier here, shown so it can be recognised,
|
||||
// and picking a locale would be answering a question nobody asked.
|
||||
b"mluc" => {
|
||||
let records = be_u32(data, 8).ok()? as usize;
|
||||
let size = be_u32(data, 12).ok()? as usize;
|
||||
if records == 0 || size < 12 {
|
||||
return None;
|
||||
}
|
||||
let length = be_u32(data, 16 + 4).ok()? as usize;
|
||||
let offset = be_u32(data, 16 + 8).ok()? as usize;
|
||||
let raw = data.get(offset..offset + length)?;
|
||||
let units: Vec<u16> = raw
|
||||
.chunks_exact(2)
|
||||
.map(|p| u16::from_be_bytes([p[0], p[1]]))
|
||||
.collect();
|
||||
Some(String::from_utf16_lossy(&units).trim().to_string()).filter(|s| !s.is_empty())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn be_u32(bytes: &[u8], at: usize) -> Result<u32, String> {
|
||||
bytes
|
||||
.get(at..at + 4)
|
||||
.map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
|
||||
.ok_or_else(|| "it ends in the middle of a field".to_string())
|
||||
}
|
||||
|
||||
fn be_i32(bytes: &[u8], at: usize) -> Result<i32, String> {
|
||||
be_u32(bytes, at).map(|v| v as i32)
|
||||
}
|
||||
|
||||
/// ICC's fixed-point number: sixteen integer bits, sixteen fractional.
|
||||
fn s15_fixed16(v: i32) -> f32 {
|
||||
v as f32 / 65536.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::display::{nearest_by_colorants, nearest_space};
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
/// A minimal matrix/TRC profile carrying one space's colorants.
|
||||
///
|
||||
/// Assembled here rather than taken from `dr-export`, deliberately. That
|
||||
/// crate writes profiles from the same `to_pcs_xyz` this reads back, so a
|
||||
/// round-trip through it would confirm the two halves of *one* set of
|
||||
/// assumptions agree with each other and would still pass if both were
|
||||
/// transposed. Laying the bytes out by hand against ICC.1's field offsets
|
||||
/// is the independent statement.
|
||||
fn profile_for(space: ColourSpace, name: &str) -> Vec<u8> {
|
||||
let m = space.to_pcs_xyz();
|
||||
let colorant = |col: usize| {
|
||||
let mut tag = b"XYZ \0\0\0\0".to_vec();
|
||||
for row in 0..3 {
|
||||
let v = (m[row * 3 + col] * 65536.0).round() as i32;
|
||||
tag.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
tag
|
||||
};
|
||||
let mut desc = b"desc\0\0\0\0".to_vec();
|
||||
let text = format!("{name}\0");
|
||||
desc.extend_from_slice(&(text.len() as u32).to_be_bytes());
|
||||
desc.extend_from_slice(text.as_bytes());
|
||||
|
||||
let tags: Vec<(&[u8; 4], Vec<u8>)> = vec![
|
||||
(b"rXYZ", colorant(0)),
|
||||
(b"gXYZ", colorant(1)),
|
||||
(b"bXYZ", colorant(2)),
|
||||
(b"desc", desc),
|
||||
];
|
||||
|
||||
let mut header = vec![0u8; HEADER];
|
||||
header[36..40].copy_from_slice(b"acsp");
|
||||
let mut table = (tags.len() as u32).to_be_bytes().to_vec();
|
||||
let mut body = Vec::new();
|
||||
let base = HEADER + 4 + tags.len() * 12;
|
||||
for (sig, data) in &tags {
|
||||
table.extend_from_slice(*sig);
|
||||
table.extend_from_slice(&((base + body.len()) as u32).to_be_bytes());
|
||||
table.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
body.extend_from_slice(data);
|
||||
}
|
||||
let mut out = header;
|
||||
out.extend_from_slice(&table);
|
||||
out.extend_from_slice(&body);
|
||||
let len = out.len() as u32;
|
||||
out[0..4].copy_from_slice(&len.to_be_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_is_recognised_as_the_space_it_describes() {
|
||||
// The whole acquisition path in one assertion: bytes that a colour
|
||||
// manager would publish for a P3 panel must reach the pipeline as
|
||||
// Display P3, and not as sRGB.
|
||||
for space in ColourSpace::ALL {
|
||||
let bytes = profile_for(space, space.label());
|
||||
let summary = read_profile(&bytes).expect("a well-formed profile");
|
||||
let (found, exact) = nearest_by_colorants(&summary.colorants);
|
||||
assert_eq!(found, space, "{} was read as {found:?}", space.label());
|
||||
assert!(
|
||||
exact,
|
||||
"{} did not survive s15Fixed16 rounding",
|
||||
space.label()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_colorant_matrix_is_not_transposed() {
|
||||
// Reading the three tags into rows rather than columns produces a
|
||||
// matrix that is still plausible, still finite, and describes a
|
||||
// different gamut. Adobe RGB is the case that catches it: it differs
|
||||
// from sRGB in one primary, so a transposition moves it onto a space
|
||||
// that is genuinely close and the nearest-match would hide it.
|
||||
let bytes = profile_for(ColourSpace::AdobeRgb, "Adobe RGB");
|
||||
let summary = read_profile(&bytes).expect("a well-formed profile");
|
||||
let want = ColourSpace::AdobeRgb.to_pcs_xyz();
|
||||
|
||||
// Not `assert_eq`: the values went out through s15Fixed16 and came
|
||||
// back, so they agree to about 1e-5 and never exactly. The tolerance
|
||||
// is three orders of magnitude tighter than the smallest off-diagonal
|
||||
// difference below, so it still catches the thing it is here for.
|
||||
for (have, want) in summary.colorants.iter().zip(want.iter()) {
|
||||
assert!((have - want).abs() < 1e-4, "{:?}", summary.colorants);
|
||||
}
|
||||
|
||||
// And the guard that makes the assertion above mean something: a
|
||||
// symmetric matrix would satisfy it transposed as well.
|
||||
assert!(
|
||||
(want[1] - want[3]).abs() > 1e-2,
|
||||
"the colorant matrix must not be symmetric or this proves nothing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_names_itself_where_it_can() {
|
||||
let bytes = profile_for(ColourSpace::Srgb, "EIZO CG279X calibrated 2024-03");
|
||||
let summary = read_profile(&bytes).expect("a well-formed profile");
|
||||
assert_eq!(
|
||||
summary.description.as_deref(),
|
||||
Some("EIZO CG279X calibrated 2024-03")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_v4_profile_names_itself_from_its_unicode_records() {
|
||||
let mut mluc = b"mluc\0\0\0\0".to_vec();
|
||||
let text: Vec<u8> = "LG UltraFine"
|
||||
.encode_utf16()
|
||||
.flat_map(u16::to_be_bytes)
|
||||
.collect();
|
||||
mluc.extend_from_slice(&1u32.to_be_bytes()); // one record
|
||||
mluc.extend_from_slice(&12u32.to_be_bytes()); // record size
|
||||
mluc.extend_from_slice(b"enUS");
|
||||
mluc.extend_from_slice(&(text.len() as u32).to_be_bytes());
|
||||
mluc.extend_from_slice(&28u32.to_be_bytes()); // offset within the tag
|
||||
mluc.extend_from_slice(&text);
|
||||
assert_eq!(text_tag(&mluc).as_deref(), Some("LG UltraFine"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lut_profile_is_refused_by_shape_and_not_by_crashing() {
|
||||
// A hardware calibrator's `mAB `-based profile. There is nothing wrong
|
||||
// with it; we simply cannot approximate it without the CMM this module
|
||||
// exists to avoid, and the About page has to be able to say so.
|
||||
let mut header = vec![0u8; HEADER];
|
||||
header[36..40].copy_from_slice(b"acsp");
|
||||
header.extend_from_slice(&0u32.to_be_bytes());
|
||||
let len = header.len() as u32;
|
||||
header[0..4].copy_from_slice(&len.to_be_bytes());
|
||||
let err = read_profile(&header).expect_err("no colorants to read");
|
||||
assert!(err.contains("matrix/TRC"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rubbish_on_a_root_window_is_refused_rather_than_read_as_colour() {
|
||||
// An X11 property is untyped and world-writable by convention. None of
|
||||
// these may panic, and none may produce a colour space.
|
||||
assert!(read_profile(&[]).is_err());
|
||||
assert!(read_profile(&[0u8; 200]).is_err());
|
||||
|
||||
let mut truncated = profile_for(ColourSpace::Srgb, "sRGB");
|
||||
truncated.truncate(truncated.len() / 2);
|
||||
assert!(read_profile(&truncated).is_err());
|
||||
|
||||
// A profile whose tag table claims far more tags than there are bytes.
|
||||
let mut absurd = profile_for(ColourSpace::Srgb, "sRGB");
|
||||
absurd[HEADER..HEADER + 4].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
assert!(read_profile(&absurd).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_two_entry_points_agree() {
|
||||
// `nearest_space` (chromaticities, from Wayland) and
|
||||
// `nearest_by_colorants` (a matrix, from ICC) must not drift apart:
|
||||
// they are the same decision reached from the two halves of the same
|
||||
// definition, and a session that answered differently depending on
|
||||
// which display server it was on would be the worst kind of bug to
|
||||
// reproduce.
|
||||
for space in ColourSpace::ALL {
|
||||
let bytes = profile_for(space, "x");
|
||||
let summary = read_profile(&bytes).expect("a well-formed profile");
|
||||
assert_eq!(
|
||||
nearest_by_colorants(&summary.colorants).0,
|
||||
nearest_space(&space.chromaticities()).0
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
//! TRACES: FR-DSP-8 | FR-PLAT-LIN-2
|
||||
//! Acquisition on Wayland: ask the compositor, or admit that we cannot.
|
||||
//!
|
||||
//! # Why there is no back door here
|
||||
//!
|
||||
//! X11's mechanism works because any client may read any root-window
|
||||
//! property. Wayland has no such shared state by design — a client sees its
|
||||
//! own surfaces and nothing else — so the profile has to be *given* to us, and
|
||||
//! the protocol that gives it, `wp_color_manager_v1`, is still staging
|
||||
//! upstream. That is the concrete form of FR-DSP-8's "a defined fallback where
|
||||
//! Wayland provides no profile": on a compositor that does not advertise the
|
||||
//! global there is no mechanism to fall back *from*, and pretending otherwise
|
||||
//! would mean reading the Xwayland root window and presenting a value the
|
||||
//! compositor never agreed to.
|
||||
//!
|
||||
//! So: bind the global if it is there, ask each `wl_output` for its image
|
||||
//! description, and where it is not there return
|
||||
//! [`FallbackReason::NoWaylandProtocol`], which the About page renders in
|
||||
//! words.
|
||||
//!
|
||||
//! # Two forms of the same answer
|
||||
//!
|
||||
//! The compositor may describe an output either by handing over an ICC profile
|
||||
//! on a file descriptor or by stating primaries as CIE xy chromaticities.
|
||||
//! Both are accepted, and both reduce to the same nearest-space decision — see
|
||||
//! `super::nearest_by_colorants` for why that reduction has to happen in one
|
||||
//! place. The ICC form is preferred where both arrive, because it carries the
|
||||
//! profile's own name, which is the string a photographer recognises.
|
||||
//!
|
||||
//! # Position is deliberately not asked for
|
||||
//!
|
||||
//! [`super::DisplaySurvey::containing`] explains the consequence at length:
|
||||
//! `bounds` is `None` on every Wayland output, not because `wl_output` refuses
|
||||
//! to state its position but because a client is never told where its *own*
|
||||
//! window is, so a rectangle to test against would have no point to test.
|
||||
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
|
||||
use wayland_client::protocol::{wl_output, wl_registry};
|
||||
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
|
||||
use wayland_protocols::wp::color_management::v1::client::{
|
||||
wp_color_management_output_v1, wp_color_manager_v1, wp_image_description_info_v1,
|
||||
wp_image_description_v1,
|
||||
};
|
||||
|
||||
use dr_types::colour::Chromaticities;
|
||||
|
||||
use super::{
|
||||
nearest_by_colorants, nearest_space, DisplayInfo, DisplayProfile, FallbackReason, ProfileSource,
|
||||
};
|
||||
|
||||
/// The highest protocol version this client knows how to receive.
|
||||
///
|
||||
/// Bound as high as the compositor offers rather than at 1, because
|
||||
/// `get_image_description` fails with `low_version` when the client cannot
|
||||
/// receive every event the output's description needs — so asking for less
|
||||
/// than we understand turns a describable display into a fallback.
|
||||
const MAX_MANAGER_VERSION: u32 = 3;
|
||||
|
||||
/// `wl_output.name` arrived in version 4. Below it an output has no stable
|
||||
/// identifier and is listed by index, which is cosmetic only.
|
||||
const MAX_OUTPUT_VERSION: u32 = 4;
|
||||
|
||||
/// What one output told us, accumulated across the events that describe it.
|
||||
#[derive(Default)]
|
||||
struct Pending {
|
||||
name: Option<String>,
|
||||
description: Option<String>,
|
||||
/// The ICC profile the compositor handed over, if it did.
|
||||
icc: Option<Vec<u8>>,
|
||||
/// Primaries as CIE xy, if it stated them instead.
|
||||
primaries: Option<Chromaticities>,
|
||||
/// Set when the image description could not be produced at all.
|
||||
failed: Option<String>,
|
||||
/// Whether anything at all came back for this output.
|
||||
answered: bool,
|
||||
}
|
||||
|
||||
struct State {
|
||||
manager: Option<wp_color_manager_v1::WpColorManagerV1>,
|
||||
outputs: Vec<wl_output::WlOutput>,
|
||||
pending: Vec<Pending>,
|
||||
}
|
||||
|
||||
/// Every output the compositor has, with whatever colour it will state.
|
||||
pub fn probe() -> Result<Vec<DisplayInfo>, FallbackReason> {
|
||||
let conn = Connection::connect_to_env()
|
||||
.map_err(|e| FallbackReason::NoConnection(format!("Wayland: {e}")))?;
|
||||
let mut queue = conn.new_event_queue();
|
||||
let qh = queue.handle();
|
||||
conn.display().get_registry(&qh, ());
|
||||
|
||||
let mut state = State {
|
||||
manager: None,
|
||||
outputs: Vec::new(),
|
||||
pending: Vec::new(),
|
||||
};
|
||||
|
||||
// First roundtrip: the registry's globals, which is where both the manager
|
||||
// and the outputs are bound.
|
||||
roundtrip(&mut queue, &mut state)?;
|
||||
// Second: the events those newly bound objects emit — `wl_output.name`,
|
||||
// and the manager's capability advertisements. A single roundtrip cannot
|
||||
// carry them, because the objects did not exist when the first request
|
||||
// went out.
|
||||
roundtrip(&mut queue, &mut state)?;
|
||||
|
||||
let Some(manager) = state.manager.clone() else {
|
||||
// The case FR-DSP-8's fallback clause is written for, and today the
|
||||
// common one. Reported as its own reason rather than folded into
|
||||
// "no profile", because the two lead a user somewhere different: this
|
||||
// one is the compositor's feature set, not their calibration.
|
||||
return Err(FallbackReason::NoWaylandProtocol);
|
||||
};
|
||||
if state.outputs.is_empty() {
|
||||
return Err(FallbackReason::NoConnection(
|
||||
"Wayland: the compositor listed no outputs".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Ask every output at once and let one roundtrip settle them all. Asking
|
||||
// serially would be a roundtrip per monitor on a path that runs at startup
|
||||
// and on every display change.
|
||||
for (index, output) in state.outputs.clone().iter().enumerate() {
|
||||
let cm_output = manager.get_output(output, &qh, index);
|
||||
cm_output.get_image_description(&qh, index);
|
||||
}
|
||||
roundtrip(&mut queue, &mut state)?;
|
||||
// `get_information` is only legal once the description is ready, so it
|
||||
// cannot be batched with the request above; this is the second and last
|
||||
// roundtrip of the exchange.
|
||||
roundtrip(&mut queue, &mut state)?;
|
||||
|
||||
Ok(state
|
||||
.pending
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, pending)| DisplayInfo {
|
||||
name: pending
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("display {}", index + 1)),
|
||||
// See the module comment: a rectangle with no point to test.
|
||||
bounds: None,
|
||||
profile: resolve(pending),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Turn one output's accumulated events into an output space.
|
||||
fn resolve(pending: &Pending) -> DisplayProfile {
|
||||
if let Some(why) = &pending.failed {
|
||||
return DisplayProfile::fallback(FallbackReason::Unreadable(format!("Wayland: {why}")));
|
||||
}
|
||||
|
||||
// The ICC form first: it is the only one that carries a name, and a named
|
||||
// profile is what lets a photographer confirm we are reading theirs.
|
||||
if let Some(bytes) = &pending.icc {
|
||||
return match super::icc::read_profile(bytes) {
|
||||
Ok(summary) => {
|
||||
let (space, exact) = nearest_by_colorants(&summary.colorants);
|
||||
DisplayProfile {
|
||||
space,
|
||||
source: ProfileSource::WaylandColourManagement,
|
||||
described_as: summary.description.or_else(|| pending.description.clone()),
|
||||
approximated: !exact,
|
||||
}
|
||||
}
|
||||
Err(why) => DisplayProfile::fallback(FallbackReason::Unreadable(why)),
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(primaries) = pending.primaries {
|
||||
let (space, exact) = nearest_space(&primaries);
|
||||
return DisplayProfile {
|
||||
space,
|
||||
source: ProfileSource::WaylandColourManagement,
|
||||
described_as: pending.description.clone(),
|
||||
approximated: !exact,
|
||||
};
|
||||
}
|
||||
|
||||
// The protocol was there and the output had nothing to say through it.
|
||||
DisplayProfile::fallback(if pending.answered {
|
||||
FallbackReason::NoProfileForDisplay
|
||||
} else {
|
||||
FallbackReason::Unreadable("Wayland: the compositor did not answer".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn roundtrip(
|
||||
queue: &mut wayland_client::EventQueue<State>,
|
||||
state: &mut State,
|
||||
) -> Result<(), FallbackReason> {
|
||||
queue
|
||||
.roundtrip(state)
|
||||
.map(|_| ())
|
||||
.map_err(|e| FallbackReason::NoConnection(format!("Wayland: {e}")))
|
||||
}
|
||||
|
||||
impl Dispatch<wl_registry::WlRegistry, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
registry: &wl_registry::WlRegistry,
|
||||
event: wl_registry::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
let wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} = event
|
||||
else {
|
||||
// `GlobalRemove` is a monitor being unplugged mid-probe. Ignored
|
||||
// here: the survey is re-run on a display change anyway, and
|
||||
// reacting to it inside a two-roundtrip exchange would only make
|
||||
// the indices disagree with the vector.
|
||||
return;
|
||||
};
|
||||
if interface == wp_color_manager_v1::WpColorManagerV1::interface().name {
|
||||
state.manager = Some(registry.bind(name, version.min(MAX_MANAGER_VERSION), qh, ()));
|
||||
} else if interface == wl_output::WlOutput::interface().name {
|
||||
let index = state.outputs.len();
|
||||
state.pending.push(Pending::default());
|
||||
state
|
||||
.outputs
|
||||
.push(registry.bind(name, version.min(MAX_OUTPUT_VERSION), qh, index));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<wl_output::WlOutput, usize> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &wl_output::WlOutput,
|
||||
event: wl_output::Event,
|
||||
index: &usize,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
let Some(pending) = state.pending.get_mut(*index) else {
|
||||
return;
|
||||
};
|
||||
match event {
|
||||
// The connector name — "DP-1", "eDP-1" — which is what a user with
|
||||
// two monitors recognises in a list.
|
||||
wl_output::Event::Name { name } => pending.name = Some(name),
|
||||
wl_output::Event::Description { description } => {
|
||||
pending.description = Some(description)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<wp_color_manager_v1::WpColorManagerV1, ()> for State {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
_: &wp_color_manager_v1::WpColorManagerV1,
|
||||
_: wp_color_manager_v1::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
// The manager advertises which intents, features, transfer functions
|
||||
// and named primaries it supports. All of that constrains what a
|
||||
// client may *create*; this one only reads what the outputs already
|
||||
// are, so none of it applies.
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<wp_color_management_output_v1::WpColorManagementOutputV1, usize> for State {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
_: &wp_color_management_output_v1::WpColorManagementOutputV1,
|
||||
_: wp_color_management_output_v1::Event,
|
||||
_: &usize,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
// `image_description_changed` says the output's colour has changed —
|
||||
// a user switching their monitor's picture mode, or assigning a new
|
||||
// profile. Not acted on here: this object lives for the length of one
|
||||
// probe and is dropped with it. The application re-surveys instead,
|
||||
// which also covers the X11 side, where there is no such event.
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<wp_image_description_v1::WpImageDescriptionV1, usize> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
description: &wp_image_description_v1::WpImageDescriptionV1,
|
||||
event: wp_image_description_v1::Event,
|
||||
index: &usize,
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
let Some(pending) = state.pending.get_mut(*index) else {
|
||||
return;
|
||||
};
|
||||
match event {
|
||||
// `ready` at protocol version 1, `ready2` from version 2. Both
|
||||
// mean the same thing and both are handled, because the version
|
||||
// bound depends on the compositor.
|
||||
wp_image_description_v1::Event::Ready { .. }
|
||||
| wp_image_description_v1::Event::Ready2 { .. } => {
|
||||
pending.answered = true;
|
||||
description.get_information(qh, *index);
|
||||
}
|
||||
wp_image_description_v1::Event::Failed { cause, msg } => {
|
||||
pending.answered = true;
|
||||
pending.failed = Some(format!("{cause:?}: {msg}"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<wp_image_description_info_v1::WpImageDescriptionInfoV1, usize> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &wp_image_description_info_v1::WpImageDescriptionInfoV1,
|
||||
event: wp_image_description_info_v1::Event,
|
||||
index: &usize,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
let Some(pending) = state.pending.get_mut(*index) else {
|
||||
return;
|
||||
};
|
||||
match event {
|
||||
wp_image_description_info_v1::Event::IccFile { icc, icc_size } => {
|
||||
match read_fd(icc, icc_size as usize) {
|
||||
Ok(bytes) => pending.icc = Some(bytes),
|
||||
Err(e) => log::warn!("reading the compositor's ICC profile: {e}"),
|
||||
}
|
||||
}
|
||||
wp_image_description_info_v1::Event::Primaries {
|
||||
r_x,
|
||||
r_y,
|
||||
g_x,
|
||||
g_y,
|
||||
b_x,
|
||||
b_y,
|
||||
w_x,
|
||||
w_y,
|
||||
} => {
|
||||
// The protocol carries six decimals by multiplying by a
|
||||
// million; undoing it here rather than anywhere downstream
|
||||
// keeps `Chromaticities` meaning one thing everywhere.
|
||||
let xy = |x: i32, y: i32| [f64::from(x) / 1e6, f64::from(y) / 1e6];
|
||||
pending.primaries = Some(Chromaticities {
|
||||
red: xy(r_x, r_y),
|
||||
green: xy(g_x, g_y),
|
||||
blue: xy(b_x, b_y),
|
||||
white: xy(w_x, w_y),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a profile out of the file descriptor the compositor passed.
|
||||
///
|
||||
/// Seeks to the start first. The descriptor is a dup of the compositor's own,
|
||||
/// so it shares a file offset with it; assuming zero is the kind of assumption
|
||||
/// that works on every compositor until it does not.
|
||||
fn read_fd(fd: std::os::fd::OwnedFd, size: usize) -> std::io::Result<Vec<u8>> {
|
||||
let mut file = std::fs::File::from(fd);
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
let mut bytes = Vec::with_capacity(size.min(1 << 20));
|
||||
// Bounded by what the compositor declared, and separately by a megabyte:
|
||||
// a display profile is a few kilobytes, and the size is a number from
|
||||
// another process.
|
||||
file.take(size.min(1 << 20) as u64)
|
||||
.read_to_end(&mut bytes)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -4,10 +4,15 @@
|
||||
//! construction, so `core/` contains no `#[cfg(target_os)]` (NFR-PORT-1,
|
||||
//! ARCH §10).
|
||||
|
||||
pub mod display;
|
||||
pub mod secrets;
|
||||
pub mod storage;
|
||||
pub mod volumes;
|
||||
|
||||
pub use display::{
|
||||
Bounds, DisplayInfo, DisplayProfile, DisplayServer, DisplaySurvey, FallbackReason,
|
||||
ProfileSource,
|
||||
};
|
||||
pub use secrets::{
|
||||
EphemeralSecretStore, PlatformSecretStore, SecretError, SecretKind, SecretRef, SecretStore,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user