Skip to main content

drmkit_display/
edid.rs

1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! What a connected display says about itself.
5//!
6//! EDID is a blob the display hands over on connect, describing its make,
7//! physical size, color primaries, and what HDR it can accept. It is also
8//! notoriously malformed in the field — truncated, mis-checksummed, carrying
9//! extension blocks that disagree with the base block — so parsing it is
10//! delegated to [`libdisplay-info`], which exists precisely because everyone
11//! who wrote their own got it wrong.
12//!
13//! This module is the translation layer: `libdisplay-info`'s view into the
14//! shapes the rest of drmkit uses, with the same field selection upstream
15//! makes.
16//!
17//! Behind the `edid` feature, because the binding links a system library and
18//! the cross lanes have no sysroot for it.
19//!
20//! [`libdisplay-info`]: https://gitlab.freedesktop.org/emersion/libdisplay-info
21
22use libdisplay_info::edid::DisplayDescriptorRef;
23use libdisplay_info::info::Info;
24
25pub use crate::color::{Chromaticity, ColorimetryInfo};
26
27/// The display's HDR static metadata block.
28///
29/// One field per flag in the CTA block, rather than a bitflags type: this is a
30/// transcription of a wire format, and a reader checking it against the
31/// standard should find the standard's field names.
32#[derive(Debug, Clone, Copy, PartialEq, Default)]
33#[expect(
34    clippy::struct_excessive_bools,
35    reason = "the shape is CTA-861's, not a design choice"
36)]
37#[non_exhaustive]
38pub struct HdrStaticMetadata {
39    /// Peak luminance the display wants content mastered to, in nits.
40    pub desired_content_max_luminance: f32,
41    /// Desired frame-average peak, in nits.
42    pub desired_content_max_frame_avg_luminance: f32,
43    /// Desired black level, in nits.
44    pub desired_content_min_luminance: f32,
45    /// Static metadata type 1 is supported.
46    pub type1: bool,
47    /// Traditional SDR gamma.
48    pub traditional_sdr: bool,
49    /// Traditional HDR gamma.
50    pub traditional_hdr: bool,
51    /// SMPTE ST 2084 — HDR10.
52    pub pq: bool,
53    /// Hybrid Log-Gamma.
54    pub hlg: bool,
55}
56
57/// Signal colorimetry encodings the display accepts beyond the default.
58#[derive(Debug, Clone, Copy, PartialEq, Default)]
59#[expect(
60    clippy::struct_excessive_bools,
61    reason = "one field per encoding in the CTA colorimetry data block"
62)]
63#[non_exhaustive]
64pub struct SupportedColorimetry {
65    /// BT.2020 constant-luminance `YCbCr`.
66    pub bt2020_cycc: bool,
67    /// BT.2020 non-constant-luminance `YCbCr`.
68    pub bt2020_ycc: bool,
69    /// BT.2020 RGB.
70    pub bt2020_rgb: bool,
71    /// SMPTE ST 2113 — P3D65 and P3DCI.
72    pub st2113_rgb: bool,
73    /// BT.2100 `ICtCp`.
74    pub ictcp: bool,
75}
76
77/// The vertical refresh range the panel states it accepts.
78///
79/// From the EDID Display Range Limits descriptor, tag `0xFD`. On a
80/// variable-refresh panel this is its VRR window; on a fixed-rate one the two
81/// are equal. Absent when the EDID carries no range descriptor, or one with a
82/// zero maximum or a minimum above its maximum, which is not the same as a
83/// panel with no range -- it is a panel that did not say.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85#[allow(
86    clippy::exhaustive_structs,
87    reason = "a value defined by its fields; there is nothing to add to it"
88)]
89pub struct VrefreshRange {
90    /// Lowest refresh in Hz.
91    pub min_hz: i32,
92    /// Highest refresh in Hz.
93    pub max_hz: i32,
94}
95
96impl VrefreshRange {
97    /// Whether the panel accepts more than one rate.
98    ///
99    /// The question a compositor is really asking before it offers
100    /// variable-refresh presentation.
101    #[must_use]
102    pub const fn is_variable(&self) -> bool {
103        self.max_hz > self.min_hz
104    }
105}
106
107/// What a connector's attached display reports.
108#[derive(Debug, Clone, PartialEq, Default)]
109#[non_exhaustive]
110pub struct ConnectorInfo {
111    /// Make and model joined, for display to a person.
112    pub name: String,
113    /// Manufacturer — a company name where the PNP ID is known, else the ID.
114    pub make: String,
115    /// Product name or model.
116    pub model: String,
117    /// Per-unit serial number, where the display gives one.
118    pub serial: Option<String>,
119    /// Physical width in millimeters.
120    pub width_mm: i32,
121    /// Physical height in millimeters.
122    pub height_mm: i32,
123    /// Color primaries, where present.
124    pub colorimetry: Option<ColorimetryInfo>,
125    /// HDR static metadata, where the display advertises any.
126    pub hdr: Option<HdrStaticMetadata>,
127    /// Extra colorimetry encodings, where any are advertised.
128    pub wide_gamut: Option<SupportedColorimetry>,
129    /// The refresh range the panel states, where it states one.
130    pub vrefresh_range: Option<VrefreshRange>,
131}
132
133/// Why an EDID blob could not be read.
134#[derive(Debug, thiserror::Error)]
135#[non_exhaustive]
136pub enum EdidError {
137    /// The blob was not parseable as EDID at all.
138    ///
139    /// A truncated read, a blob that is not EDID, or one malformed past what
140    /// the parser is willing to guess at.
141    #[error("EDID blob could not be parsed")]
142    Unparseable,
143}
144
145/// Read a display's EDID blob.
146///
147/// # Errors
148///
149/// [`EdidError::Unparseable`] if the blob is not EDID.
150pub fn parse_edid(blob: &[u8]) -> Result<ConnectorInfo, EdidError> {
151    let info = Info::parse_edid(blob).map_err(|_| EdidError::Unparseable)?;
152
153    let make = info.make().unwrap_or_default();
154    let model = info.model().unwrap_or_default();
155    // Joined the way upstream joins them, with a space only when both halves
156    // are there — a display that reports only a model should read as its model,
157    // not as a leading space.
158    let name = match (make.is_empty(), model.is_empty()) {
159        (false, false) => format!("{make} {model}"),
160        (false, true) => make.clone(),
161        (true, false) => model.clone(),
162        (true, true) => String::new(),
163    };
164
165    let (mut width_mm, mut height_mm) = (0, 0);
166    if let Some(edid) = info.edid() {
167        let size = edid.screen_size();
168        // EDID records whole centimeters; everything downstream works in
169        // millimeters, so convert here rather than leaving two units in play.
170        width_mm = size.width_cm.unwrap_or(0) * 10;
171        height_mm = size.height_cm.unwrap_or(0) * 10;
172    }
173
174    let primaries = info.default_color_primaries();
175    let colorimetry =
176        (primaries.has_primaries || primaries.has_default_white_point).then(|| ColorimetryInfo {
177            red: Chromaticity {
178                x: primaries.primary[0].x,
179                y: primaries.primary[0].y,
180            },
181            green: Chromaticity {
182                x: primaries.primary[1].x,
183                y: primaries.primary[1].y,
184            },
185            blue: Chromaticity {
186                x: primaries.primary[2].x,
187                y: primaries.primary[2].y,
188            },
189            white: Chromaticity {
190                x: primaries.default_white.x,
191                y: primaries.default_white.y,
192            },
193            has_primaries: primaries.has_primaries,
194            has_default_white: primaries.has_default_white_point,
195        });
196
197    let raw_hdr = info.hdr_static_metadata();
198    // `traditional_sdr` is deliberately absent from this test.
199    // libdisplay-info sets it for any base EDID, so a gate that counted it
200    // would be `Some` for every display ever made — the defect upstream had
201    // until `1b435c3`, arrived at differently. This is *HDR* static metadata:
202    // traditional SDR gamma alone means the display said nothing about HDR.
203    let advertises_hdr = raw_hdr.type1 || raw_hdr.traditional_hdr || raw_hdr.pq || raw_hdr.hlg;
204    let hdr = advertises_hdr.then_some(HdrStaticMetadata {
205        desired_content_max_luminance: raw_hdr.desired_content_max_luminance,
206        desired_content_max_frame_avg_luminance: raw_hdr.desired_content_max_frame_avg_luminance,
207        desired_content_min_luminance: raw_hdr.desired_content_min_luminance,
208        type1: raw_hdr.type1,
209        traditional_sdr: raw_hdr.traditional_sdr,
210        traditional_hdr: raw_hdr.traditional_hdr,
211        pq: raw_hdr.pq,
212        hlg: raw_hdr.hlg,
213    });
214
215    let raw_gamut = info.supported_signal_colorimetry();
216    let any_gamut = raw_gamut.bt2020_cycc
217        || raw_gamut.bt2020_ycc
218        || raw_gamut.bt2020_rgb
219        || raw_gamut.st2113_rgb
220        || raw_gamut.ictcp;
221    let wide_gamut = any_gamut.then_some(SupportedColorimetry {
222        bt2020_cycc: raw_gamut.bt2020_cycc,
223        bt2020_ycc: raw_gamut.bt2020_ycc,
224        bt2020_rgb: raw_gamut.bt2020_rgb,
225        st2113_rgb: raw_gamut.st2113_rgb,
226        ictcp: raw_gamut.ictcp,
227    });
228
229    // One descriptor slot in four carries this, and only if the panel used it.
230    // A range of zero to zero is a descriptor that exists and says nothing,
231    // which is the same information as no descriptor at all.
232    let vrefresh_range = info
233        .edid()
234        .and_then(|edid| {
235            edid.display_descriptors()
236                .iter()
237                .find_map(DisplayDescriptorRef::range_limits)
238        })
239        .map(|limits| VrefreshRange {
240            min_hz: limits.min_vert_rate_hz,
241            max_hz: limits.max_vert_rate_hz,
242        })
243        .filter(|range| range.max_hz > 0 && range.min_hz <= range.max_hz);
244
245    Ok(ConnectorInfo {
246        name,
247        make,
248        model,
249        serial: info.serial(),
250        width_mm,
251        height_mm,
252        colorimetry,
253        hdr,
254        wide_gamut,
255        vrefresh_range,
256    })
257}