git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/units.rs (14.7K)

  1 //! Lengths with units, and the one bridge between them and the screen.
  2 //!
  3 //! The toolkit's working unit is and stays the **logical pixel**: every
  4 //! layout node, style slot and widget measure is an `f32` of logical px, as
  5 //! it always was. This module adds the two things that were missing:
  6 //!
  7 //! - [`Len`] — a length that remembers its unit (`px`, `mm`, `cm`, `in`,
  8 //!   `pt`), parsed from config (`width=(mm)2.0`, or the string `"2mm"`)
  9 //!   and resolved to logical px through a [`Metric`].
 10 //! - [`Metric`] — how many logical px one millimetre covers on the display
 11 //!   this process is on, and where that number came from. Measured from the
 12 //!   output's EDID size when the compositor reports one, configured by the
 13 //!   user when EDID lies, forced by `CCE_FORCE_PPI` for headless shadows,
 14 //!   or *assumed* at the CSS convention of 96 logical px per inch when
 15 //!   nothing better is known. The source is carried, not hidden: an
 16 //!   assumed metric is a guess, and anything fabricating from it should
 17 //!   say so.
 18 //!
 19 //! Why the toolkit is not converted to millimetres internally: UI sizes are
 20 //! perceptual and angular, not physical. A hit target should not become 8 mm
 21 //! on a projector three metres away. Documents and fabrication content are
 22 //! the things that live in real units, and they convert at view time. Two
 23 //! domains, one bridge — this one.
 24 //!
 25 //! The process-wide metric lives here ([`metric`] / [`set_metric`]), fed by
 26 //! the window runner from the Wayland output the surface is on, exactly as
 27 //! `scale::scale_factor` is. Style slots carrying a unit resolve through it
 28 //! at every read, so a metric arriving after config load, or changing when
 29 //! the window moves to another display, is honoured without a reload.
 30 
 31 use std::fmt;
 32 use std::sync::{OnceLock, RwLock};
 33 
 34 /// Millimetres per inch.
 35 pub const MM_PER_INCH: f32 = 25.4;
 36 /// Points per inch (PostScript/CSS points).
 37 pub const PT_PER_INCH: f32 = 72.0;
 38 /// The CSS reference pixel: what a logical px is taken to measure when the
 39 /// display's real size is unknown. Same convention as the `Xft.dpi 96×scale`
 40 /// the compositor writes for Xwayland, so a bare pixel keeps its meaning
 41 /// under the fallback.
 42 pub const ASSUMED_PPI: f32 = 96.0;
 43 
 44 /// A length unit. `Px` is the logical pixel; the rest are real-world.
 45 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
 46 pub enum Unit {
 47     Px,
 48     Mm,
 49     Cm,
 50     M,
 51     In,
 52     Pt,
 53 }
 54 
 55 impl Unit {
 56     /// Every unit, in the order a unit toggle should cycle them.
 57     pub const ALL: [Unit; 6] = [Unit::Px, Unit::Mm, Unit::Cm, Unit::M, Unit::In, Unit::Pt];
 58 
 59     /// The config suffix / KDL type annotation: `px`, `mm`, `cm`, `in`, `pt`.
 60     pub fn suffix(self) -> &'static str {
 61         match self {
 62             Unit::Px => "px",
 63             Unit::Mm => "mm",
 64             Unit::Cm => "cm",
 65             Unit::M => "m",
 66             Unit::In => "in",
 67             Unit::Pt => "pt",
 68         }
 69     }
 70 
 71     /// Parse a suffix or KDL type annotation. `None` for anything else, so a
 72     /// caller can tell "not a unit" from a unit — `(f64)` is not a length.
 73     pub fn parse(s: &str) -> Option<Unit> {
 74         match s.trim().to_ascii_lowercase().as_str() {
 75             "px" => Some(Unit::Px),
 76             "mm" => Some(Unit::Mm),
 77             "cm" => Some(Unit::Cm),
 78             "m" | "metre" | "meter" | "metres" | "meters" => Some(Unit::M),
 79             "in" | "inch" | "inches" => Some(Unit::In),
 80             "pt" => Some(Unit::Pt),
 81             _ => None,
 82         }
 83     }
 84 
 85     /// Whether this unit is a real-world length (everything but `Px`).
 86     pub fn is_physical(self) -> bool {
 87         !matches!(self, Unit::Px)
 88     }
 89 
 90     /// Millimetres per one of this unit. `None` for `Px`, whose size depends
 91     /// on the metric.
 92     fn mm_per_unit(self) -> Option<f32> {
 93         match self {
 94             Unit::Px => None,
 95             Unit::Mm => Some(1.0),
 96             Unit::Cm => Some(10.0),
 97             Unit::M => Some(1000.0),
 98             Unit::In => Some(MM_PER_INCH),
 99             Unit::Pt => Some(MM_PER_INCH / PT_PER_INCH),
100         }
101     }
102 }
103 
104 impl fmt::Display for Unit {
105     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106         f.write_str(self.suffix())
107     }
108 }
109 
110 /// Where a [`Metric`]'s px-per-mm came from — carried so a consumer can tell
111 /// a measurement from a guess.
112 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
113 pub enum MetricSource {
114     /// Computed from the output's reported physical size (EDID via
115     /// `wl_output` geometry) and its logical size.
116     Measured,
117     /// The user's per-output `size_mm` override, forwarded by the compositor
118     /// in place of the EDID value.
119     Configured,
120     /// `CCE_FORCE_PPI` in the environment.
121     Forced,
122     /// Nothing known: the CSS 96 px/in convention. A guess.
123     Assumed,
124 }
125 
126 impl MetricSource {
127     pub fn as_str(self) -> &'static str {
128         match self {
129             MetricSource::Measured => "measured",
130             MetricSource::Configured => "configured",
131             MetricSource::Forced => "forced",
132             MetricSource::Assumed => "assumed",
133         }
134     }
135 }
136 
137 /// The bridge between logical pixels and real lengths for one display.
138 #[derive(Debug, Clone, Copy, PartialEq)]
139 pub struct Metric {
140     /// The output scale (logical → physical px), as `scale::scale_factor`.
141     pub scale: f32,
142     /// Logical px per millimetre.
143     pub px_per_mm: f32,
144     pub source: MetricSource,
145 }
146 
147 impl Metric {
148     /// The fallback metric: 96 logical px per inch, flagged as assumed.
149     pub fn assumed(scale: f32) -> Self {
150         Metric { scale, px_per_mm: ASSUMED_PPI / MM_PER_INCH, source: MetricSource::Assumed }
151     }
152 
153     /// A metric from a display's logical size and physical size in mm.
154     /// `None` when either is unusable (zero, negative, or an implausible
155     /// density outside 25–1000 logical px per inch — an EDID that reports
156     /// the 16×9 cm a TV likes to claim is a lie, not a measurement).
157     pub fn from_sizes(scale: f32, logical_px: (f32, f32), mm: (f32, f32), source: MetricSource) -> Option<Self> {
158         if logical_px.0 <= 0.0 || logical_px.1 <= 0.0 || mm.0 <= 0.0 || mm.1 <= 0.0 {
159             return None;
160         }
161         // Average the two axes: EDID rounds each to the millimetre, and a
162         // panel's pixels are square, so the mean is closer than either.
163         let px_per_mm = 0.5 * (logical_px.0 / mm.0 + logical_px.1 / mm.1);
164         Self::from_px_per_mm(scale, px_per_mm, source)
165     }
166 
167     /// A metric from a density directly, with the same plausibility gate.
168     pub fn from_px_per_mm(scale: f32, px_per_mm: f32, source: MetricSource) -> Option<Self> {
169         let ppi = px_per_mm * MM_PER_INCH;
170         if !ppi.is_finite() || !(25.0..=1000.0).contains(&ppi) {
171             return None;
172         }
173         Some(Metric { scale, px_per_mm, source })
174     }
175 
176     /// Logical px per inch.
177     pub fn ppi(&self) -> f32 {
178         self.px_per_mm * MM_PER_INCH
179     }
180 
181     /// Physical (buffer) px per millimetre.
182     pub fn physical_px_per_mm(&self) -> f32 {
183         self.px_per_mm * self.scale
184     }
185 
186     /// Millimetres per logical px.
187     pub fn mm_per_px(&self) -> f32 {
188         1.0 / self.px_per_mm
189     }
190 
191     /// Whether this metric was measured or configured, i.e. safe to
192     /// dimension real objects from.
193     pub fn is_real(&self) -> bool {
194         matches!(self.source, MetricSource::Measured | MetricSource::Configured)
195     }
196 
197     /// Logical px for `value` of `unit`.
198     pub fn to_px(&self, value: f32, unit: Unit) -> f32 {
199         match unit.mm_per_unit() {
200             None => value,
201             Some(mm) => value * mm * self.px_per_mm,
202         }
203     }
204 
205     /// `unit` for a length of `px` logical px.
206     pub fn from_px(&self, px: f32, unit: Unit) -> f32 {
207         match unit.mm_per_unit() {
208             None => px,
209             Some(mm) => px / (mm * self.px_per_mm),
210         }
211     }
212 }
213 
214 /// A length that remembers its unit. Resolve it with [`Len::resolve`] (or
215 /// [`Len::px`] against the process metric) exactly once, at the boundary
216 /// where a config or document value becomes a layout number.
217 #[derive(Debug, Clone, Copy, PartialEq)]
218 pub struct Len {
219     pub value: f32,
220     pub unit: Unit,
221 }
222 
223 impl Len {
224     pub const fn new(value: f32, unit: Unit) -> Self {
225         Len { value, unit }
226     }
227     pub const fn px(value: f32) -> Self {
228         Len::new(value, Unit::Px)
229     }
230     pub const fn mm(value: f32) -> Self {
231         Len::new(value, Unit::Mm)
232     }
233     pub const fn cm(value: f32) -> Self {
234         Len::new(value, Unit::Cm)
235     }
236     pub const fn m(value: f32) -> Self {
237         Len::new(value, Unit::M)
238     }
239     pub const fn inches(value: f32) -> Self {
240         Len::new(value, Unit::In)
241     }
242     pub const fn pt(value: f32) -> Self {
243         Len::new(value, Unit::Pt)
244     }
245 
246     /// Parse `"2mm"`, `"0.5 in"`, `"12px"`, `"6pt"`. A bare number is
247     /// `None`: the caller decides what an unsuffixed number means (in
248     /// config it is a logical px and takes the fast path), and this parser
249     /// only ever claims a value that *said* its unit.
250     pub fn parse(s: &str) -> Option<Len> {
251         let s = s.trim();
252         let split = s.find(|c: char| c.is_ascii_alphabetic())?;
253         let (num, suffix) = s.split_at(split);
254         let value = num.trim().parse::<f32>().ok().filter(|v| v.is_finite())?;
255         let unit = Unit::parse(suffix)?;
256         Some(Len { value, unit })
257     }
258 
259     /// Parse a number plus a KDL type annotation (`(mm)2.0`): `None` when
260     /// the annotation is not a unit.
261     pub fn from_annotated(value: f32, annotation: &str) -> Option<Len> {
262         Unit::parse(annotation).map(|unit| Len { value, unit })
263     }
264 
265     /// Logical px under `metric`.
266     pub fn resolve(&self, metric: &Metric) -> f32 {
267         metric.to_px(self.value, self.unit)
268     }
269 
270     /// Logical px under the process metric.
271     pub fn to_px(&self) -> f32 {
272         self.resolve(&metric())
273     }
274 
275     /// The same length expressed in `unit` under `metric`.
276     pub fn convert(&self, unit: Unit, metric: &Metric) -> Len {
277         Len { value: metric.from_px(self.resolve(metric), unit), unit }
278     }
279 
280     /// The compact form `parse` reads back: `2mm`, `9.3px`. Trailing zeros
281     /// trimmed so a config line stays as the user typed it.
282     pub fn serialize(&self) -> String {
283         format!("{}{}", fmt_num(self.value), self.unit.suffix())
284     }
285 }
286 
287 impl fmt::Display for Len {
288     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289         f.write_str(&self.serialize())
290     }
291 }
292 
293 /// A number with up to four decimals, trailing zeros dropped.
294 pub fn fmt_num(v: f32) -> String {
295     let s = format!("{:.4}", v);
296     let s = s.trim_end_matches('0').trim_end_matches('.');
297     if s.is_empty() || s == "-" { "0".to_string() } else { s.to_string() }
298 }
299 
300 static METRIC: RwLock<Metric> = RwLock::new(Metric {
301     scale: 1.0,
302     px_per_mm: ASSUMED_PPI / MM_PER_INCH,
303     source: MetricSource::Assumed,
304 });
305 static FORCED_PPI: OnceLock<Option<f32>> = OnceLock::new();
306 
307 /// `CCE_FORCE_PPI=<logical px per inch>`: pin the metric regardless of what
308 /// the outputs report — a headless shadow has no EDID and would otherwise
309 /// run assumed, so a test that measures a millimetre sets this to the live
310 /// panel's figure (141.8 on the 3840×2400 / 344 mm laptop at scale 2).
311 pub fn forced_ppi() -> Option<f32> {
312     *FORCED_PPI.get_or_init(|| {
313         std::env::var("CCE_FORCE_PPI")
314             .ok()
315             .and_then(|v| v.parse::<f32>().ok())
316             .filter(|p| p.is_finite() && *p > 0.0)
317     })
318 }
319 
320 /// The process-wide metric.
321 pub fn metric() -> Metric {
322     *METRIC.read().unwrap()
323 }
324 
325 /// Install the process-wide metric. A forced PPI overrides everything but
326 /// keeps the caller's scale. Called by the window runner as outputs come and
327 /// go; apps only read.
328 pub fn set_metric(m: Metric) {
329     let m = match forced_ppi() {
330         Some(ppi) => Metric { scale: m.scale, px_per_mm: ppi / MM_PER_INCH, source: MetricSource::Forced },
331         None => m,
332     };
333     if let Ok(mut lock) = METRIC.write() {
334         if *lock != m {
335             log::info!(
336                 "[units] metric: {:.3} logical px/mm ({:.1} ppi, scale {}) — {}",
337                 m.px_per_mm,
338                 m.ppi(),
339                 m.scale,
340                 m.source.as_str()
341             );
342         }
343         *lock = m;
344     }
345 }
346 
347 /// Logical px per millimetre under the process metric.
348 pub fn px_per_mm() -> f32 {
349     metric().px_per_mm
350 }
351 
352 /// Logical px for `v` millimetres under the process metric.
353 pub fn mm(v: f32) -> f32 {
354     metric().to_px(v, Unit::Mm)
355 }
356 
357 #[cfg(test)]
358 mod tests {
359     use super::*;
360 
361     /// The live laptop panel: 3840×2400 over 344×215 mm at scale 2.
362     fn panel() -> Metric {
363         Metric::from_sizes(2.0, (1920.0, 1200.0), (344.0, 215.0), MetricSource::Measured).unwrap()
364     }
365 
366     #[test]
367     fn panel_metric_is_about_5_6_px_per_mm() {
368         let m = panel();
369         assert!((m.px_per_mm - 5.58).abs() < 0.02, "{}", m.px_per_mm);
370         assert!((m.ppi() - 141.8).abs() < 0.5);
371         assert!((m.physical_px_per_mm() - 11.16).abs() < 0.05);
372         assert!(m.is_real());
373     }
374 
375     #[test]
376     fn assumed_is_css_px() {
377         let m = Metric::assumed(1.0);
378         assert_eq!(Len::inches(1.0).resolve(&m), 96.0);
379         assert!((Len::pt(72.0).resolve(&m) - 96.0).abs() < 1e-4);
380         assert!(!m.is_real());
381     }
382 
383     #[test]
384     fn implausible_sizes_reject() {
385         // An EDID claiming 16×9 mm at 4K: 240 px/mm, nonsense. (The gate is
386         // deliberately wide — a 4K panel over 16×9 *cm* is 610 ppi, which a
387         // phone-class panel can be — so only the absurd is refused; the
388         // `size_mm` override exists for the merely wrong.)
389         assert!(Metric::from_sizes(1.0, (3840.0, 2160.0), (16.0, 9.0), MetricSource::Measured).is_none());
390         assert!(Metric::from_sizes(1.0, (1920.0, 1080.0), (0.0, 0.0), MetricSource::Measured).is_none());
391     }
392 
393     #[test]
394     fn parse_and_serialize_roundtrip() {
395         for s in ["2mm", "0.5in", "12px", "6pt", "1.25cm", "0.3m"] {
396             let l = Len::parse(s).unwrap();
397             assert_eq!(l.serialize(), s, "{s}");
398         }
399         assert_eq!(Len::parse("2 mm"), Some(Len::mm(2.0)));
400         assert_eq!(Len::parse("2"), None, "bare numbers are the caller's");
401         assert_eq!(Len::parse("2em"), None);
402         assert_eq!(Len::parse("mm"), None);
403         assert_eq!(Len::from_annotated(2.0, "mm"), Some(Len::mm(2.0)));
404         assert_eq!(Len::from_annotated(2.0, "f64"), None);
405     }
406 
407     #[test]
408     fn resolve_and_convert() {
409         let m = panel();
410         let roll = Len::px(9.3);
411         let in_mm = roll.convert(Unit::Mm, &m);
412         assert!((in_mm.value - 1.67).abs() < 0.01, "{in_mm}");
413         assert!((Len::mm(1.0).resolve(&m) - 5.58).abs() < 0.02);
414         assert_eq!(Len::px(4.0).resolve(&m), 4.0);
415     }
416 
417     #[test]
418     fn fmt_num_trims() {
419         assert_eq!(fmt_num(2.0), "2");
420         assert_eq!(fmt_num(9.3), "9.3");
421         assert_eq!(fmt_num(0.0), "0");
422         assert_eq!(fmt_num(1.23456), "1.2346");
423     }
424 }