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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user