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 }