The rest of FR-DSP-8. The develop session now carries the space its canvas is encoded into, and `render` composes for it instead of for sRGB — which is the whole of the change to the pixel path, because the output space was always a parameter of composition and always entered the structure hash. A display change is a recomposition. The space is set on the way into every render rather than pushed when the window moves, so a photograph opened while the window already sits on the second monitor is right on its first frame instead of flashing the wrong colour until the next poll. Which display that is comes from sampling the window's position and scale factor twice a second — Slint reports neither a move nor a display change — and re-surveying only when they differ. Settings shows what came back under ABOUT: the display, the space, why, and the other monitors, because the failure FR-DSP-8 names is one that is invisible from the display you are reading the page on. Fractional scaling: the canvas is now rendered at the physical pixel size of the box it occupies rather than the logical one, so the compositor presents it 1:1. At 1.25 it was previously handed 1600 samples to fill 2000 device pixels, and the softness that produces reads like a bad demosaic rather than like a scaling bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
567 lines
24 KiB
Rust
567 lines
24 KiB
Rust
//! 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, 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 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<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 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);
|
|
}
|
|
}
|