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

commit1e61967aca2a48b9c2ffe3c59a353bb973e805ff
parente79f3860ff
authorLucas Galante <[email protected]>
date2026-09-09 14:53
feat(units): lengths with units, and the display metric they resolve through

The first step of the unit system. The toolkit's working unit stays the
logical pixel; this adds the one bridge to real lengths:

- `units::Len` — a value with a unit (px, mm, cm, in, pt). In config a
  length carries its unit as a KDL type annotation, like `(rgba)` and
  `(relief)`: `width=(mm)2.0`. A bare number is a logical px, forever.
  `kdl_to_json` turns an annotated number into the string "2mm", the
  writer turns it back into `(mm)2`, a typed write over a `(mm)` slot
  keeps the unit, and `reload_config` stores it in the style registry's
  new `lens` map — `get_float` resolves it against the live metric at
  EVERY read, so every existing getter is unit-aware without knowing it
  and a metric that arrives after config load is honoured.
- `units::Metric` — logical px per mm for this display, with its source
  carried: measured (wl_output geometry, EDID or the compositor's
  configured size_mm), forced (`CCE_FORCE_PPI`), or assumed — the CSS
  96 ppi convention when nothing is known, flagged so fabrication can
  refuse a guess. `wayland::detect_metric` reads it from the same output
  the scale comes from (xdg-output logical size when present); the
  window runner installs it beside `scale::set_scale_factor`.

Verified in a scale-2 headless shadow: with the compositor's
size_mm="344x215" a rebuilt client logs 1.767 logical px/mm, measured,
matching `ccectl outputs` to three decimals. Unit tests cover parse /
serialize / resolve, the config round trip, and registry resolution.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 CLAUDE.md                    |  44 ++++-
 src/backend/window_runner.rs |   3 +
 src/config.rs                | 161 ++++++++++-------
 src/layout.rs                |  56 ++++++
 src/lib.rs                   |   1 +
 src/units.rs                 | 417 +++++++++++++++++++++++++++++++++++++++++++
 src/wayland.rs               |  40 +++++
 7 files changed, 653 insertions(+), 69 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 673cdbc..0415335 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -264,7 +264,46 @@ cce-system-interface) to confirm behavior, not just the test suite.
   name) → a file on disk, plus `upload_themed` to rasterize/decode and upload it.
   **Not** `lib.rs`'s `upload_icon`, which loads a *bundled* cce-icons glyph by its
   own name for in-widget use; this one resolves names any installed app may ship.
-- `file_dialog.rs` (rfd), `scale.rs` (HiDPI), `wayland.rs` (surface/scale detection).
+- `file_dialog.rs` (rfd), `scale.rs` (HiDPI), `wayland.rs` (surface/scale detection, and
+  `detect_metric` — the display's logical px per mm from its `wl_output` geometry).
+- `units.rs` — lengths with units and the display metric; see the Units section below.
+
+## Units — logical px inside, real lengths at the edges
+
+The toolkit's working unit is and stays the **logical pixel**: every layout
+node, style slot and widget measure is an `f32` of logical px. `units.rs`
+adds the bridge to real lengths, in two parts:
+
+- **`Len`** — a value with a unit (`px`, `mm`, `cm`, `in`, `pt`), parsed from
+  `"2mm"` and resolved to logical px through a `Metric`. In config a length
+  carries its unit as a KDL type annotation, the same way `(rgba)` and
+  `(relief)` do: `width=(mm)2.0`. A bare number is a logical px, forever —
+  nothing migrates. `config::kdl_to_json` turns an annotated number into the
+  string `"2mm"`; the writer turns it back into `(mm)2`; `reload_config`
+  stores it in the style registry's `lens` map, and `get_float` resolves it
+  against the live metric at every read. So `layout::bevel_width()` and every
+  other getter are unit-aware without knowing it, and a metric that arrives
+  after config load (outputs come in after the first style read) or changes
+  with the display is honoured without a reload. `get_len` returns the
+  configured unit for editors that should show what the user typed.
+- **`Metric`** — logical px per mm for the display this process is on, plus
+  its **source**: `measured` (EDID via `wl_output` geometry, or the
+  compositor's configured `size_mm` in its place — the client cannot tell
+  them apart; `ccectl outputs` can), `forced` (`CCE_FORCE_PPI`), or
+  `assumed` — the CSS 96 px/in convention when nothing is known (a headless
+  shadow, a projector with no EDID). The source is carried so fabrication
+  can refuse a guess: `Metric::is_real()`. The window runner installs it
+  beside `scale::set_scale_factor` (`units::set_metric`); apps read
+  `units::metric()`, `units::mm(v)`, or `Len::to_px()`.
+
+Why not millimetres inside: UI sizes are perceptual and angular, not physical
+— a hit target should not become 8 mm on a projector three metres away.
+Documents and fabrication content live in real units and convert at view
+time. Two domains, one bridge.
+
+On the live laptop panel (3840×2400 over 344×215 mm at scale 2) the metric is
+5.58 logical px/mm (141.8 ppi); the default 9.3 px relief roll is 1.67 mm, and
+the 96 ppi assumption would have called it 2.46 mm.
 
 ## Fonts & assets
 
@@ -291,4 +330,7 @@ All opt-in, all read once, all quiet when unset — set one and run any client.
 - `CCE_PRESENT_DEBUG=1` — swapchain present/acquire tracing.
 - `CCE_VK_DEVICE=<substring>` — force a physical device; `CCE_VK_RT=0` disables ray tracing.
 - `CCE_FORCE_SCALE=<f>` — override HiDPI scale detection.
+- `CCE_FORCE_PPI=<f>` — pin the display metric (logical px per inch) regardless of what
+  the outputs report; a headless shadow has no EDID and would run `assumed`. The live
+  panel is 141.8.
 - `CCE_UI_FAULT_RECONNECT=1` — exercise the Wayland reconnect path.
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index f2e7bc8..4d97110 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -4240,10 +4240,12 @@ impl<A: Application> OutputHandler for EngineState<A> {
     fn new_output(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _output: wl_output::WlOutput) {
         let scale = crate::wayland::detect_scale_factor(&self.output_state);
         crate::scale::set_scale_factor(scale as f32);
+        crate::units::set_metric(crate::wayland::detect_metric(&self.output_state, scale));
     }
     fn update_output(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _output: wl_output::WlOutput) {
         let scale = crate::wayland::detect_scale_factor(&self.output_state);
         crate::scale::set_scale_factor(scale as f32);
+        crate::units::set_metric(crate::wayland::detect_metric(&self.output_state, scale));
     }
     fn output_destroyed(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _output: wl_output::WlOutput) {}
 }
@@ -5573,6 +5575,7 @@ fn run_session<'l, A: Application>(
     let scale = detect_scale_factor(&engine_state.output_state);
     engine_state.scale_factor = scale;
     crate::scale::set_scale_factor(scale as f32);
+    crate::units::set_metric(crate::wayland::detect_metric(&engine_state.output_state, scale));
 
     // A reconnect re-attaches the SAME app: its state is the thing worth
     // saving, and `A::new` would both discard it and hand a fresh Sender to
diff --git a/src/config.rs b/src/config.rs
index aff8969..155344b 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,6 +1,45 @@
 use std::fs;
 use serde_json::Value;
 
+/// A unit-annotated number (`width=(mm)2.0`, `(px)9.3`, `(in)0.5`,
+/// `(pt)6`) becomes the JSON string `"2mm"` — the form
+/// `crate::units::Len::parse` reads — so the unit survives the JSON hop
+/// into the style registry, where it resolves against the live metric at
+/// every read. A bare number stays a number: a logical px, as always.
+fn unit_entry_to_json(value: f64, entry: &kdl::KdlEntry) -> Option<serde_json::Value> {
+    let ty = entry.ty()?.value();
+    let len = crate::units::Len::from_annotated(value as f32, ty)?;
+    Some(serde_json::Value::String(len.serialize()))
+}
+
+fn int_entry_to_json(value: i64, entry: &kdl::KdlEntry) -> serde_json::Value {
+    unit_entry_to_json(value as f64, entry)
+        .unwrap_or_else(|| serde_json::Value::Number(serde_json::Number::from(value)))
+}
+
+fn float_entry_to_json(value: f64, entry: &kdl::KdlEntry) -> serde_json::Value {
+    if let Some(v) = unit_entry_to_json(value, entry) {
+        return v;
+    }
+    let mut val_f = value;
+    if let Some(ty) = entry.ty() {
+        let ty_str = ty.value();
+        if let Some(range_str) = ty_str.strip_prefix("f64:") {
+            if let Some(dash_idx) = range_str.find('-') {
+                let min_str = range_str[..dash_idx].trim();
+                let max_str = range_str[dash_idx + 1..].trim();
+                if let (Ok(min_f), Ok(max_f)) = (min_str.parse::<f64>(), max_str.parse::<f64>()) {
+                    val_f = val_f.clamp(min_f, max_f);
+                }
+            }
+        }
+    }
+    match serde_json::Number::from_f64(val_f) {
+        Some(num) => serde_json::Value::Number(num),
+        None => serde_json::Value::Null,
+    }
+}
+
 fn kdl_to_json(doc: &kdl::KdlDocument) -> serde_json::Value {
     let mut map = serde_json::Map::new();
     for node in doc.nodes() {
@@ -16,28 +55,8 @@ fn kdl_to_json(doc: &kdl::KdlDocument) -> serde_json::Value {
                     kdl::KdlValue::Base2(i) |
                     kdl::KdlValue::Base8(i) |
                     kdl::KdlValue::Base10(i) |
-                    kdl::KdlValue::Base16(i) => serde_json::Value::Number(serde_json::Number::from(*i)),
-                    kdl::KdlValue::Base10Float(f) => {
-                        let mut val_f = *f;
-                        if let Some(ty) = entry.ty() {
-                            let ty_str = ty.value();
-                            if ty_str.starts_with("f64:") {
-                                let range_str = ty_str.trim_start_matches("f64:");
-                                if let Some(dash_idx) = range_str.find('-') {
-                                    let min_str = &range_str[..dash_idx].trim();
-                                    let max_str = &range_str[dash_idx + 1..].trim();
-                                    if let (Ok(min_f), Ok(max_f)) = (min_str.parse::<f64>(), max_str.parse::<f64>()) {
-                                        val_f = val_f.clamp(min_f, max_f);
-                                    }
-                                }
-                            }
-                        }
-                        if let Some(num) = serde_json::Number::from_f64(val_f) {
-                            serde_json::Value::Number(num)
-                        } else {
-                            serde_json::Value::Null
-                        }
-                    }
+                    kdl::KdlValue::Base16(i) => int_entry_to_json(*i, entry),
+                    kdl::KdlValue::Base10Float(f) => float_entry_to_json(*f, entry),
                     kdl::KdlValue::String(s) |
                     kdl::KdlValue::RawString(s) => serde_json::Value::String(s.clone()),
                     kdl::KdlValue::Null => serde_json::Value::Null,
@@ -58,28 +77,8 @@ fn kdl_to_json(doc: &kdl::KdlDocument) -> serde_json::Value {
                                 kdl::KdlValue::Base2(i) |
                                 kdl::KdlValue::Base8(i) |
                                 kdl::KdlValue::Base10(i) |
-                                kdl::KdlValue::Base16(i) => serde_json::Value::Number(serde_json::Number::from(*i)),
-                                kdl::KdlValue::Base10Float(f) => {
-                                    let mut val_f = *f;
-                                    if let Some(ty) = entry.ty() {
-                                        let ty_str = ty.value();
-                                        if ty_str.starts_with("f64:") {
-                                            let range_str = ty_str.trim_start_matches("f64:");
-                                            if let Some(dash_idx) = range_str.find('-') {
-                                                let min_str = &range_str[..dash_idx].trim();
-                                                let max_str = &range_str[dash_idx + 1..].trim();
-                                                if let (Ok(min_f), Ok(max_f)) = (min_str.parse::<f64>(), max_str.parse::<f64>()) {
-                                                    val_f = val_f.clamp(min_f, max_f);
-                                                }
-                                            }
-                                        }
-                                    }
-                                    if let Some(num) = serde_json::Number::from_f64(val_f) {
-                                        serde_json::Value::Number(num)
-                                    } else {
-                                        serde_json::Value::Null
-                                    }
-                                }
+                                kdl::KdlValue::Base16(i) => int_entry_to_json(*i, entry),
+                                kdl::KdlValue::Base10Float(f) => float_entry_to_json(*f, entry),
                                 kdl::KdlValue::String(s) |
                                 kdl::KdlValue::RawString(s) => serde_json::Value::String(s.clone()),
                                 kdl::KdlValue::Null => serde_json::Value::Null,
@@ -135,28 +134,8 @@ fn kdl_to_json(doc: &kdl::KdlDocument) -> serde_json::Value {
                     kdl::KdlValue::Base2(i) |
                     kdl::KdlValue::Base8(i) |
                     kdl::KdlValue::Base10(i) |
-                    kdl::KdlValue::Base16(i) => serde_json::Value::Number(serde_json::Number::from(*i)),
-                    kdl::KdlValue::Base10Float(f) => {
-                        let mut val_f = *f;
-                        if let Some(ty) = entry.ty() {
-                            let ty_str = ty.value();
-                            if ty_str.starts_with("f64:") {
-                                let range_str = ty_str.trim_start_matches("f64:");
-                                if let Some(dash_idx) = range_str.find('-') {
-                                    let min_str = &range_str[..dash_idx].trim();
-                                    let max_str = &range_str[dash_idx + 1..].trim();
-                                    if let (Ok(min_f), Ok(max_f)) = (min_str.parse::<f64>(), max_str.parse::<f64>()) {
-                                        val_f = val_f.clamp(min_f, max_f);
-                                    }
-                                }
-                            }
-                        }
-                        if let Some(num) = serde_json::Number::from_f64(val_f) {
-                            serde_json::Value::Number(num)
-                        } else {
-                            serde_json::Value::Null
-                        }
-                    }
+                    kdl::KdlValue::Base16(i) => int_entry_to_json(*i, entry),
+                    kdl::KdlValue::Base10Float(f) => float_entry_to_json(*f, entry),
                     kdl::KdlValue::String(s) |
                     kdl::KdlValue::RawString(s) => serde_json::Value::String(s.clone()),
                     kdl::KdlValue::Null => serde_json::Value::Null,
@@ -413,6 +392,10 @@ pub fn update_kdl_in_memory_typed(doc: &mut kdl::KdlDocument, key: &str, value:
 
     let (kdl_val, mut kdl_ty) = if let Ok(b) = value.parse::<bool>() {
         (kdl::KdlValue::Bool(b), Some("bool".to_string()))
+    } else if let Some(len) = crate::units::Len::parse(value) {
+        // `2mm` → `(mm)2.0`: the unit rides as the annotation, the value
+        // stays a number the editor's spinbox can step.
+        (kdl::KdlValue::Base10Float(len.value as f64), Some(len.unit.suffix().to_string()))
     } else if value.starts_with('#') {
         let s_clean = value.trim_start_matches('#');
         let ty = if s_clean.len() == 8 { "rgba" } else { "rgb" };
@@ -434,6 +417,12 @@ pub fn update_kdl_in_memory_typed(doc: &mut kdl::KdlDocument, key: &str, value:
         if ext_ty.starts_with("menu:") || ext_ty == "button" || ext_ty.starts_with("button:") || ext_ty == "vec2i" || ext_ty == "radian" || ext_ty == "bevel" || ext_ty == "keybind" {
             kdl_ty = Some(ext_ty.clone());
         }
+        // A bare number written over a unit-annotated slot keeps the unit:
+        // typing 3 into a `(mm)` field means 3 mm, not a silent fall back
+        // to logical px.
+        if crate::units::Unit::parse(ext_ty).is_some() && matches!(kdl_val, kdl::KdlValue::Base10Float(_) | kdl::KdlValue::Base10(_)) && kdl_ty.as_deref().map_or(true, |t| t == "f64" || t == "i64") {
+            kdl_ty = Some(ext_ty.clone());
+        }
     }
     if let Some(f) = forced_ty {
         kdl_ty = Some(f.to_string());
@@ -823,6 +812,38 @@ pub fn get_kdl_type_annotations(kdl_content: &str, key_paths: &[String]) -> Vec<
 
 #[cfg(test)]
 mod tests {
+    #[test]
+    fn unit_annotations_become_len_strings() {
+        let v = parse_kdl_to_json("style {\n    relief width=(mm)2.0 depth=(f64)0.15 lip=(px)6\n    ruler (in)0.5\n}\n");
+        assert_eq!(v["style"]["relief"]["width"], serde_json::json!("2mm"));
+        assert_eq!(v["style"]["relief"]["depth"], serde_json::json!(0.15));
+        assert_eq!(v["style"]["relief"]["lip"], serde_json::json!("6px"));
+        assert_eq!(v["style"]["ruler"], serde_json::json!("0.5in"));
+    }
+
+    #[test]
+    fn unit_strings_write_back_annotated() {
+        let v = serde_json::json!({"style": {"relief": {"width": "2mm", "depth": 0.15}}});
+        let out = json_to_kdl_string(&v);
+        assert!(out.contains("width=(mm)2\n") || out.contains("width=(mm)2 "), "{out}");
+        assert!(out.contains("depth=(f64)0.15"), "{out}");
+        let back = parse_kdl_to_json(&out);
+        assert_eq!(back["style"]["relief"]["width"], serde_json::json!("2mm"));
+    }
+
+    #[test]
+    fn typed_write_keeps_and_sets_units() {
+        let mut doc: kdl::KdlDocument = "style {\n    relief width=(mm)2.0\n}\n".parse().unwrap();
+        // A bare number over a (mm) slot stays mm.
+        assert!(update_kdl_in_memory_typed(&mut doc, "style.relief.width", "3", "style", None));
+        let v = parse_kdl_to_json(&doc.to_string());
+        assert_eq!(v["style"]["relief"]["width"], serde_json::json!("3mm"));
+        // A suffixed value sets the unit.
+        assert!(update_kdl_in_memory_typed(&mut doc, "style.relief.width", "0.25in", "style", None));
+        let v = parse_kdl_to_json(&doc.to_string());
+        assert_eq!(v["style"]["relief"]["width"], serde_json::json!("0.25in"));
+    }
+
     #[test]
     fn app_name_strips_the_kernels_deleted_marker() {
         use super::app_name_from_exe_basename as name;
@@ -1200,7 +1221,9 @@ pub fn value_to_kdl_with_annotations(
                                 }
                             }
                             serde_json::Value::String(s) => {
-                                if let Some(anno) = annotations.get(&prop_path) {
+                                if let Some(len) = crate::units::Len::parse(s) {
+                                    (crate::units::fmt_num(len.value), Some(len.unit.suffix().to_string()))
+                                } else if let Some(anno) = annotations.get(&prop_path) {
                                     if anno == "vec2i" {
                                         (s.clone(), Some(anno.clone()))
                                     } else {
@@ -1270,7 +1293,9 @@ pub fn value_to_kdl_with_annotations(
                     }
                 }
                 serde_json::Value::String(s) => {
-                    if let Some(anno) = annotations.get(&current_path) {
+                    if let Some(len) = crate::units::Len::parse(s) {
+                        (crate::units::fmt_num(len.value), Some(len.unit.suffix().to_string()))
+                    } else if let Some(anno) = annotations.get(&current_path) {
                         if anno == "vec2i" {
                             (s.clone(), Some(anno.clone()))
                         } else {
diff --git a/src/layout.rs b/src/layout.rs
index a686bc9..d5d5d64 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -8,6 +8,12 @@ use std::sync::OnceLock;
 pub struct StyleRegistry {
     pub floats: HashMap<String, f32>,
     pub strings: HashMap<String, String>,
+    /// Slots whose config value carried a unit (`width=(mm)2.0`). Read
+    /// through `get_float` like any other number, resolved against the
+    /// process metric (`crate::units::metric`) at EVERY read, so a metric
+    /// that arrives after config load — outputs come in after the first
+    /// style read — or changes with the display is honoured live.
+    pub lens: HashMap<String, crate::units::Len>,
 }
 
 impl StyleRegistry {
@@ -15,21 +21,43 @@ impl StyleRegistry {
         Self {
             floats: HashMap::new(),
             strings: HashMap::new(),
+            lens: HashMap::new(),
         }
     }
 
     pub fn get_float(&self, key: &str) -> Option<f32> {
+        if let Some(len) = self.lens.get(key) {
+            return Some(len.to_px());
+        }
         self.floats.get(key).copied()
     }
 
+    /// The slot as a length with its unit: the configured `Len` when one was
+    /// given, else the plain number as logical px. For editors that show the
+    /// unit the user chose rather than the resolved pixel count.
+    pub fn get_len(&self, key: &str) -> Option<crate::units::Len> {
+        if let Some(len) = self.lens.get(key) {
+            return Some(*len);
+        }
+        self.floats.get(key).map(|v| crate::units::Len::px(*v))
+    }
+
     pub fn get_string(&self, key: &str) -> Option<String> {
         self.strings.get(key).cloned()
     }
 
+    /// A plain number wins over any earlier unit value for the slot — a
+    /// runtime `set_float` is the newest opinion.
     pub fn set_float(&mut self, key: &str, val: f32) {
+        self.lens.remove(key);
         self.floats.insert(key.to_string(), val);
     }
 
+    pub fn set_len(&mut self, key: &str, len: crate::units::Len) {
+        self.floats.remove(key);
+        self.lens.insert(key.to_string(), len);
+    }
+
     pub fn set_string(&mut self, key: &str, val: String) {
         self.strings.insert(key.to_string(), val);
     }
@@ -451,6 +479,9 @@ pub fn reload_config() {
                 if let Ok(mut registry) = get_style_registry().write() {
                     if let Ok(f_val) = val_str.parse::<f32>() {
                         registry.set_float(&key, f_val);
+                    } else if let Some(len) = crate::units::Len::parse(val_str) {
+                        // `(mm)2.0` arrived as the string `2mm`.
+                        registry.set_len(&key, len);
                     } else {
                         registry.set_string(&key, val_str.to_string());
                     }
@@ -5845,6 +5876,31 @@ impl crate::widget::ContainerLayout for RadialLayout {
 
 #[cfg(test)]
 mod tests {
+    #[test]
+    fn unit_slots_resolve_through_the_metric_at_read_time() {
+        use crate::units::{Len, Metric, MetricSource};
+        let mut reg = super::StyleRegistry::new();
+        reg.set_len("probe_width", Len::mm(2.0));
+        let assumed = Metric::assumed(1.0);
+        // The registry resolves against the process metric; pin it to a
+        // known value for the read, then restore.
+        let before = crate::units::metric();
+        crate::units::set_metric(assumed);
+        let px_assumed = reg.get_float("probe_width").unwrap();
+        assert!((px_assumed - 2.0 * 96.0 / 25.4).abs() < 1e-3, "{px_assumed}");
+        let panel = Metric::from_sizes(2.0, (1920.0, 1200.0), (344.0, 215.0), MetricSource::Measured).unwrap();
+        crate::units::set_metric(panel);
+        let px_panel = reg.get_float("probe_width").unwrap();
+        assert!((px_panel - 2.0 * panel.px_per_mm).abs() < 1e-3, "{px_panel}");
+        assert_ne!(px_assumed, px_panel, "a metric change is honoured without a reload");
+        assert_eq!(reg.get_len("probe_width"), Some(Len::mm(2.0)));
+        // A plain number written later wins, and reads back as px.
+        reg.set_float("probe_width", 7.0);
+        assert_eq!(reg.get_float("probe_width"), Some(7.0));
+        assert_eq!(reg.get_len("probe_width"), Some(Len::px(7.0)));
+        crate::units::set_metric(before);
+    }
+
     use super::*;
 
     struct MockRenderTarget {
diff --git a/src/lib.rs b/src/lib.rs
index 2791354..058de85 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -9,6 +9,7 @@ pub mod wayland;
 pub mod protocol;
 pub mod engine;
 pub mod scale;
+pub mod units;
 pub mod backend;
 pub mod context;
 pub mod scene;
diff --git a/src/units.rs b/src/units.rs
new file mode 100644
index 0000000..44bcc65
--- /dev/null
+++ b/src/units.rs
@@ -0,0 +1,417 @@
+//! Lengths with units, and the one bridge between them and the screen.
+//!
+//! The toolkit's working unit is and stays the **logical pixel**: every
+//! layout node, style slot and widget measure is an `f32` of logical px, as
+//! it always was. This module adds the two things that were missing:
+//!
+//! - [`Len`] — a length that remembers its unit (`px`, `mm`, `cm`, `in`,
+//!   `pt`), parsed from config (`width=(mm)2.0`, or the string `"2mm"`)
+//!   and resolved to logical px through a [`Metric`].
+//! - [`Metric`] — how many logical px one millimetre covers on the display
+//!   this process is on, and where that number came from. Measured from the
+//!   output's EDID size when the compositor reports one, configured by the
+//!   user when EDID lies, forced by `CCE_FORCE_PPI` for headless shadows,
+//!   or *assumed* at the CSS convention of 96 logical px per inch when
+//!   nothing better is known. The source is carried, not hidden: an
+//!   assumed metric is a guess, and anything fabricating from it should
+//!   say so.
+//!
+//! Why the toolkit is not converted to millimetres internally: UI sizes are
+//! perceptual and angular, not physical. A hit target should not become 8 mm
+//! on a projector three metres away. Documents and fabrication content are
+//! the things that live in real units, and they convert at view time. Two
+//! domains, one bridge — this one.
+//!
+//! The process-wide metric lives here ([`metric`] / [`set_metric`]), fed by
+//! the window runner from the Wayland output the surface is on, exactly as
+//! `scale::scale_factor` is. Style slots carrying a unit resolve through it
+//! at every read, so a metric arriving after config load, or changing when
+//! the window moves to another display, is honoured without a reload.
+
+use std::fmt;
+use std::sync::{OnceLock, RwLock};
+
+/// Millimetres per inch.
+pub const MM_PER_INCH: f32 = 25.4;
+/// Points per inch (PostScript/CSS points).
+pub const PT_PER_INCH: f32 = 72.0;
+/// The CSS reference pixel: what a logical px is taken to measure when the
+/// display's real size is unknown. Same convention as the `Xft.dpi 96×scale`
+/// the compositor writes for Xwayland, so a bare pixel keeps its meaning
+/// under the fallback.
+pub const ASSUMED_PPI: f32 = 96.0;
+
+/// A length unit. `Px` is the logical pixel; the rest are real-world.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum Unit {
+    Px,
+    Mm,
+    Cm,
+    In,
+    Pt,
+}
+
+impl Unit {
+    /// Every unit, in the order a unit toggle should cycle them.
+    pub const ALL: [Unit; 5] = [Unit::Px, Unit::Mm, Unit::Cm, Unit::In, Unit::Pt];
+
+    /// The config suffix / KDL type annotation: `px`, `mm`, `cm`, `in`, `pt`.
+    pub fn suffix(self) -> &'static str {
+        match self {
+            Unit::Px => "px",
+            Unit::Mm => "mm",
+            Unit::Cm => "cm",
+            Unit::In => "in",
+            Unit::Pt => "pt",
+        }
+    }
+
+    /// Parse a suffix or KDL type annotation. `None` for anything else, so a
+    /// caller can tell "not a unit" from a unit — `(f64)` is not a length.
+    pub fn parse(s: &str) -> Option<Unit> {
+        match s.trim().to_ascii_lowercase().as_str() {
+            "px" => Some(Unit::Px),
+            "mm" => Some(Unit::Mm),
+            "cm" => Some(Unit::Cm),
+            "in" | "inch" | "inches" => Some(Unit::In),
+            "pt" => Some(Unit::Pt),
+            _ => None,
+        }
+    }
+
+    /// Whether this unit is a real-world length (everything but `Px`).
+    pub fn is_physical(self) -> bool {
+        !matches!(self, Unit::Px)
+    }
+
+    /// Millimetres per one of this unit. `None` for `Px`, whose size depends
+    /// on the metric.
+    fn mm_per_unit(self) -> Option<f32> {
+        match self {
+            Unit::Px => None,
+            Unit::Mm => Some(1.0),
+            Unit::Cm => Some(10.0),
+            Unit::In => Some(MM_PER_INCH),
+            Unit::Pt => Some(MM_PER_INCH / PT_PER_INCH),
+        }
+    }
+}
+
+impl fmt::Display for Unit {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.write_str(self.suffix())
+    }
+}
+
+/// Where a [`Metric`]'s px-per-mm came from — carried so a consumer can tell
+/// a measurement from a guess.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum MetricSource {
+    /// Computed from the output's reported physical size (EDID via
+    /// `wl_output` geometry) and its logical size.
+    Measured,
+    /// The user's per-output `size_mm` override, forwarded by the compositor
+    /// in place of the EDID value.
+    Configured,
+    /// `CCE_FORCE_PPI` in the environment.
+    Forced,
+    /// Nothing known: the CSS 96 px/in convention. A guess.
+    Assumed,
+}
+
+impl MetricSource {
+    pub fn as_str(self) -> &'static str {
+        match self {
+            MetricSource::Measured => "measured",
+            MetricSource::Configured => "configured",
+            MetricSource::Forced => "forced",
+            MetricSource::Assumed => "assumed",
+        }
+    }
+}
+
+/// The bridge between logical pixels and real lengths for one display.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Metric {
+    /// The output scale (logical → physical px), as `scale::scale_factor`.
+    pub scale: f32,
+    /// Logical px per millimetre.
+    pub px_per_mm: f32,
+    pub source: MetricSource,
+}
+
+impl Metric {
+    /// The fallback metric: 96 logical px per inch, flagged as assumed.
+    pub fn assumed(scale: f32) -> Self {
+        Metric { scale, px_per_mm: ASSUMED_PPI / MM_PER_INCH, source: MetricSource::Assumed }
+    }
+
+    /// A metric from a display's logical size and physical size in mm.
+    /// `None` when either is unusable (zero, negative, or an implausible
+    /// density outside 25–1000 logical px per inch — an EDID that reports
+    /// the 16×9 cm a TV likes to claim is a lie, not a measurement).
+    pub fn from_sizes(scale: f32, logical_px: (f32, f32), mm: (f32, f32), source: MetricSource) -> Option<Self> {
+        if logical_px.0 <= 0.0 || logical_px.1 <= 0.0 || mm.0 <= 0.0 || mm.1 <= 0.0 {
+            return None;
+        }
+        // Average the two axes: EDID rounds each to the millimetre, and a
+        // panel's pixels are square, so the mean is closer than either.
+        let px_per_mm = 0.5 * (logical_px.0 / mm.0 + logical_px.1 / mm.1);
+        Self::from_px_per_mm(scale, px_per_mm, source)
+    }
+
+    /// A metric from a density directly, with the same plausibility gate.
+    pub fn from_px_per_mm(scale: f32, px_per_mm: f32, source: MetricSource) -> Option<Self> {
+        let ppi = px_per_mm * MM_PER_INCH;
+        if !ppi.is_finite() || !(25.0..=1000.0).contains(&ppi) {
+            return None;
+        }
+        Some(Metric { scale, px_per_mm, source })
+    }
+
+    /// Logical px per inch.
+    pub fn ppi(&self) -> f32 {
+        self.px_per_mm * MM_PER_INCH
+    }
+
+    /// Physical (buffer) px per millimetre.
+    pub fn physical_px_per_mm(&self) -> f32 {
+        self.px_per_mm * self.scale
+    }
+
+    /// Millimetres per logical px.
+    pub fn mm_per_px(&self) -> f32 {
+        1.0 / self.px_per_mm
+    }
+
+    /// Whether this metric was measured or configured, i.e. safe to
+    /// dimension real objects from.
+    pub fn is_real(&self) -> bool {
+        matches!(self.source, MetricSource::Measured | MetricSource::Configured)
+    }
+
+    /// Logical px for `value` of `unit`.
+    pub fn to_px(&self, value: f32, unit: Unit) -> f32 {
+        match unit.mm_per_unit() {
+            None => value,
+            Some(mm) => value * mm * self.px_per_mm,
+        }
+    }
+
+    /// `unit` for a length of `px` logical px.
+    pub fn from_px(&self, px: f32, unit: Unit) -> f32 {
+        match unit.mm_per_unit() {
+            None => px,
+            Some(mm) => px / (mm * self.px_per_mm),
+        }
+    }
+}
+
+/// A length that remembers its unit. Resolve it with [`Len::resolve`] (or
+/// [`Len::px`] against the process metric) exactly once, at the boundary
+/// where a config or document value becomes a layout number.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Len {
+    pub value: f32,
+    pub unit: Unit,
+}
+
+impl Len {
+    pub const fn new(value: f32, unit: Unit) -> Self {
+        Len { value, unit }
+    }
+    pub const fn px(value: f32) -> Self {
+        Len::new(value, Unit::Px)
+    }
+    pub const fn mm(value: f32) -> Self {
+        Len::new(value, Unit::Mm)
+    }
+    pub const fn cm(value: f32) -> Self {
+        Len::new(value, Unit::Cm)
+    }
+    pub const fn inches(value: f32) -> Self {
+        Len::new(value, Unit::In)
+    }
+    pub const fn pt(value: f32) -> Self {
+        Len::new(value, Unit::Pt)
+    }
+
+    /// Parse `"2mm"`, `"0.5 in"`, `"12px"`, `"6pt"`. A bare number is
+    /// `None`: the caller decides what an unsuffixed number means (in
+    /// config it is a logical px and takes the fast path), and this parser
+    /// only ever claims a value that *said* its unit.
+    pub fn parse(s: &str) -> Option<Len> {
+        let s = s.trim();
+        let split = s.find(|c: char| c.is_ascii_alphabetic())?;
+        let (num, suffix) = s.split_at(split);
+        let value = num.trim().parse::<f32>().ok().filter(|v| v.is_finite())?;
+        let unit = Unit::parse(suffix)?;
+        Some(Len { value, unit })
+    }
+
+    /// Parse a number plus a KDL type annotation (`(mm)2.0`): `None` when
+    /// the annotation is not a unit.
+    pub fn from_annotated(value: f32, annotation: &str) -> Option<Len> {
+        Unit::parse(annotation).map(|unit| Len { value, unit })
+    }
+
+    /// Logical px under `metric`.
+    pub fn resolve(&self, metric: &Metric) -> f32 {
+        metric.to_px(self.value, self.unit)
+    }
+
+    /// Logical px under the process metric.
+    pub fn to_px(&self) -> f32 {
+        self.resolve(&metric())
+    }
+
+    /// The same length expressed in `unit` under `metric`.
+    pub fn convert(&self, unit: Unit, metric: &Metric) -> Len {
+        Len { value: metric.from_px(self.resolve(metric), unit), unit }
+    }
+
+    /// The compact form `parse` reads back: `2mm`, `9.3px`. Trailing zeros
+    /// trimmed so a config line stays as the user typed it.
+    pub fn serialize(&self) -> String {
+        format!("{}{}", fmt_num(self.value), self.unit.suffix())
+    }
+}
+
+impl fmt::Display for Len {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.write_str(&self.serialize())
+    }
+}
+
+/// A number with up to four decimals, trailing zeros dropped.
+pub fn fmt_num(v: f32) -> String {
+    let s = format!("{:.4}", v);
+    let s = s.trim_end_matches('0').trim_end_matches('.');
+    if s.is_empty() || s == "-" { "0".to_string() } else { s.to_string() }
+}
+
+static METRIC: RwLock<Metric> = RwLock::new(Metric {
+    scale: 1.0,
+    px_per_mm: ASSUMED_PPI / MM_PER_INCH,
+    source: MetricSource::Assumed,
+});
+static FORCED_PPI: OnceLock<Option<f32>> = OnceLock::new();
+
+/// `CCE_FORCE_PPI=<logical px per inch>`: pin the metric regardless of what
+/// the outputs report — a headless shadow has no EDID and would otherwise
+/// run assumed, so a test that measures a millimetre sets this to the live
+/// panel's figure (141.8 on the 3840×2400 / 344 mm laptop at scale 2).
+pub fn forced_ppi() -> Option<f32> {
+    *FORCED_PPI.get_or_init(|| {
+        std::env::var("CCE_FORCE_PPI")
+            .ok()
+            .and_then(|v| v.parse::<f32>().ok())
+            .filter(|p| p.is_finite() && *p > 0.0)
+    })
+}
+
+/// The process-wide metric.
+pub fn metric() -> Metric {
+    *METRIC.read().unwrap()
+}
+
+/// Install the process-wide metric. A forced PPI overrides everything but
+/// keeps the caller's scale. Called by the window runner as outputs come and
+/// go; apps only read.
+pub fn set_metric(m: Metric) {
+    let m = match forced_ppi() {
+        Some(ppi) => Metric { scale: m.scale, px_per_mm: ppi / MM_PER_INCH, source: MetricSource::Forced },
+        None => m,
+    };
+    if let Ok(mut lock) = METRIC.write() {
+        if *lock != m {
+            log::info!(
+                "[units] metric: {:.3} logical px/mm ({:.1} ppi, scale {}) — {}",
+                m.px_per_mm,
+                m.ppi(),
+                m.scale,
+                m.source.as_str()
+            );
+        }
+        *lock = m;
+    }
+}
+
+/// Logical px per millimetre under the process metric.
+pub fn px_per_mm() -> f32 {
+    metric().px_per_mm
+}
+
+/// Logical px for `v` millimetres under the process metric.
+pub fn mm(v: f32) -> f32 {
+    metric().to_px(v, Unit::Mm)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// The live laptop panel: 3840×2400 over 344×215 mm at scale 2.
+    fn panel() -> Metric {
+        Metric::from_sizes(2.0, (1920.0, 1200.0), (344.0, 215.0), MetricSource::Measured).unwrap()
+    }
+
+    #[test]
+    fn panel_metric_is_about_5_6_px_per_mm() {
+        let m = panel();
+        assert!((m.px_per_mm - 5.58).abs() < 0.02, "{}", m.px_per_mm);
+        assert!((m.ppi() - 141.8).abs() < 0.5);
+        assert!((m.physical_px_per_mm() - 11.16).abs() < 0.05);
+        assert!(m.is_real());
+    }
+
+    #[test]
+    fn assumed_is_css_px() {
+        let m = Metric::assumed(1.0);
+        assert_eq!(Len::inches(1.0).resolve(&m), 96.0);
+        assert!((Len::pt(72.0).resolve(&m) - 96.0).abs() < 1e-4);
+        assert!(!m.is_real());
+    }
+
+    #[test]
+    fn implausible_sizes_reject() {
+        // An EDID claiming 16×9 mm at 4K: 240 px/mm, nonsense. (The gate is
+        // deliberately wide — a 4K panel over 16×9 *cm* is 610 ppi, which a
+        // phone-class panel can be — so only the absurd is refused; the
+        // `size_mm` override exists for the merely wrong.)
+        assert!(Metric::from_sizes(1.0, (3840.0, 2160.0), (16.0, 9.0), MetricSource::Measured).is_none());
+        assert!(Metric::from_sizes(1.0, (1920.0, 1080.0), (0.0, 0.0), MetricSource::Measured).is_none());
+    }
+
+    #[test]
+    fn parse_and_serialize_roundtrip() {
+        for s in ["2mm", "0.5in", "12px", "6pt", "1.25cm"] {
+            let l = Len::parse(s).unwrap();
+            assert_eq!(l.serialize(), s, "{s}");
+        }
+        assert_eq!(Len::parse("2 mm"), Some(Len::mm(2.0)));
+        assert_eq!(Len::parse("2"), None, "bare numbers are the caller's");
+        assert_eq!(Len::parse("2em"), None);
+        assert_eq!(Len::parse("mm"), None);
+        assert_eq!(Len::from_annotated(2.0, "mm"), Some(Len::mm(2.0)));
+        assert_eq!(Len::from_annotated(2.0, "f64"), None);
+    }
+
+    #[test]
+    fn resolve_and_convert() {
+        let m = panel();
+        let roll = Len::px(9.3);
+        let in_mm = roll.convert(Unit::Mm, &m);
+        assert!((in_mm.value - 1.67).abs() < 0.01, "{in_mm}");
+        assert!((Len::mm(1.0).resolve(&m) - 5.58).abs() < 0.02);
+        assert_eq!(Len::px(4.0).resolve(&m), 4.0);
+    }
+
+    #[test]
+    fn fmt_num_trims() {
+        assert_eq!(fmt_num(2.0), "2");
+        assert_eq!(fmt_num(9.3), "9.3");
+        assert_eq!(fmt_num(0.0), "0");
+        assert_eq!(fmt_num(1.23456), "1.2346");
+    }
+}
diff --git a/src/wayland.rs b/src/wayland.rs
index f452da9..0884a2b 100644
--- a/src/wayland.rs
+++ b/src/wayland.rs
@@ -49,6 +49,46 @@ pub fn detect_scale_factor(output_state: &OutputState) -> f64 {
     max_scale
 }
 
+/// The display metric (logical px per mm) for the output `detect_scale_factor`
+/// chose — the same selection rule, so scale and metric describe one
+/// display. Measured from the output's `wl_output` geometry (EDID physical
+/// size, or the compositor's configured override in its place — the client
+/// cannot tell the two apart, and reports "measured" for both; `ccectl
+/// outputs` says which) against its logical size: xdg-output's when the
+/// compositor sends one (exact under fractional scale), else the current
+/// mode divided by the integer `wl_output` scale. Outputs with no physical
+/// size (headless, a virtual output, an EDID-less projector) or an
+/// implausible one fall back to the assumed CSS metric, flagged as such.
+pub fn detect_metric(output_state: &OutputState, scale: f64) -> crate::units::Metric {
+    use crate::units::{Metric, MetricSource};
+    let scale_f = scale as f32;
+    let mut best: Option<Metric> = None;
+    for output in output_state.outputs() {
+        let Some(info) = output_state.info(&output) else { continue };
+        if (info.scale_factor as f64) < scale && crate::scale::forced_scale().is_none() {
+            // Not the display the scale was taken from.
+            continue;
+        }
+        let (mm_w, mm_h) = info.physical_size;
+        if mm_w <= 0 || mm_h <= 0 {
+            continue;
+        }
+        let logical = match info.logical_size {
+            Some((w, h)) if w > 0 && h > 0 => (w as f32, h as f32),
+            _ => {
+                let Some(mode) = info.modes.iter().find(|m| m.current) else { continue };
+                let s = (info.scale_factor.max(1)) as f32;
+                (mode.dimensions.0 as f32 / s, mode.dimensions.1 as f32 / s)
+            }
+        };
+        if let Some(m) = Metric::from_sizes(scale_f, logical, (mm_w as f32, mm_h as f32), MetricSource::Measured) {
+            best = Some(m);
+            break;
+        }
+    }
+    best.unwrap_or_else(|| Metric::assumed(scale_f))
+}
+
 /// Helper to convert a logical pointer position (from Wayland/SCTK events)
 /// to physical pixel coordinates based on the display scale factor.
 pub fn scale_pointer_pos(pos: (f64, f64), scale: f64) -> (f32, f32) {