//! 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_` 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, /// 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, if the /// server said. `None` on Wayland, which deliberately does not tell a /// client where anything is; see [`DisplaySurvey::containing`]. pub bounds: Option, pub profile: DisplayProfile, } /// A display's rectangle in the display server's own screen coordinates. /// /// Physical device pixels on X11, which is what RandR states a CRTC's origin /// and size in — and, not by coincidence, the same space Slint reports a /// window's position in. The two are comparable without conversion, and a /// conversion inserted "for tidiness" is how they would stop being. #[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, } 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, 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 at `(x, y)` in the display server's /// screen 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); } }