GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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(¤t_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(¤t_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) {