git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commit68e1f53e25cca48e3ae9b11a02dcc61882f24c35
parentaf0d6c1ac1
authorLucas Galante <[email protected]>
date2026-09-09 14:53
feat(output): per-output size_mm override and `ccectl outputs`

The first step of the unit system: the compositor now owns the physical
size every client measures against. `output { <name> size_mm="WxH" }`
replaces the EDID figure on the wlr_output before its wl_output global
is created, so the geometry event every client sees carries the
configured millimetres — for panels whose EDID lies (TVs, projectors)
or is absent (headless). Two C shim accessors expose the opaque
phys_width/phys_height fields.

`ccectl outputs [--json]` lists, per output, mode, scale, logical size,
mm, logical px per mm and where the mm came from (configured, measured,
none); output creation logs the same so an assumed-96-ppi session is
visible in the log.

Verified in a scale-2 headless shadow: HEADLESS-1 reports mm=0x0
source=none; with size_mm="344x215" it reports 1.767 px/mm
source=configured, a client's wl_output.geometry carries (344, 215), and
a cce-ui client's metric log agrees to three decimals.

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

 CLAUDE.md                        | 13 ++++++++
 WORKSPACE.md                     |  3 +-
 src/cce_ctl.rs                   |  1 +
 src/server/config.rs             | 52 ++++++++++++++++++++++++++++++++
 src/server/output.rs             | 24 +++++++++++++++
 src/server/window_manager.rs     | 65 ++++++++++++++++++++++++++++++++++++++++
 src/server/wlroots_log_wrapper.c | 10 +++++++
 wrapper.h                        |  5 ++++
 8 files changed, 172 insertions(+), 1 deletion(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 4052cd7..012e6a4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -457,6 +457,19 @@ border/blur/desktop styling, keybindings → `Action`s, startup programs, output
 settings). Live reconfiguration comes in over IPC (`ccectl reload`, `bind`, `layout …`,
 `config-done`, etc.).
 
+Per-output settings live under `output { <name> … }` as properties or child nodes:
+`scale`, `brightness_interval` / `brightness_up` / `brightness_down`, and
+**`size_mm="344x215"`** — the panel's real size, written into the `wlr_output`'s
+physical size (via the `river_wlr_output_set_phys_size` shim) *before* its
+`wl_output` global exists, so every client's geometry event carries it in place of
+the EDID figure. That is the number cce-ui's `units::Metric` divides the logical
+size by to resolve a `(mm)` config length (see `../cce-ui/CLAUDE.md`, Units). Set it
+when EDID lies (TVs, projectors) or is absent (headless, the shadow: `HEADLESS-1`
+reports 0×0 and clients fall back to an assumed 96 ppi). `ccectl outputs [--json]`
+prints, per output, mode / scale / logical size / mm / logical px per mm and where
+the mm came from (`configured`, `measured`, `none`); the creation log line says the
+same.
+
 Persistent window state is saved to **`~/.local/state/cce/state.json`**
 (`XDG_STATE_HOME/cce/state.json`) on shutdown and restored on start
 (`save_state` / `load_state` / `spawn_restored_windows`). A window's
diff --git a/WORKSPACE.md b/WORKSPACE.md
index 599d7cc..177f8bb 100644
--- a/WORKSPACE.md
+++ b/WORKSPACE.md
@@ -238,7 +238,8 @@ framework. Understanding it is the prerequisite for touching any client.
 - **Modules**: `widget/` (containers, inputs, editor, `json_layout`), `layout.rs`
   (fonts + sizing, lots of `*_font_parsed()` getters), `color.rs`, `config.rs`,
   `protocol.rs` (talking to the compositor), `context.rs`, `process.rs`,
-  `file_dialog.rs`, `scale.rs` (HiDPI), `mcp.rs` (tools-only MCP server over
+  `file_dialog.rs`, `scale.rs` (HiDPI), `units.rs` (lengths with units — `(mm)` config
+  values — and the display metric from EDID), `mcp.rs` (tools-only MCP server over
   Streamable HTTP so apps can expose their state/actions to AI agents —
   `cce-designer` is the reference consumer, see its CLAUDE.md).
 
diff --git a/src/cce_ctl.rs b/src/cce_ctl.rs
index d83d98c..e622cf4 100644
--- a/src/cce_ctl.rs
+++ b/src/cce_ctl.rs
@@ -63,6 +63,7 @@ fn usage(name: &str, to_stderr: bool) {
     print("                              # keeping its size and growing away from its neighbours");
     print("  overview                   # toggle; the exit lands on the focused window, not the pointer");
     print("  windows [--json]           # list windows; --json emits one JSON object per line");
+    print("  outputs [--json]           # list outputs: mode, scale, logical size, physical mm, px/mm, and where the mm came from");
     print("  status-hide-mode [true|false]");
     print("  adjust-position-mode [true|false|query]");
     print("  exit [force]               # log out; waits for windows to close, cancels if one stays (a save prompt); force skips the wait");
diff --git a/src/server/config.rs b/src/server/config.rs
index df58989..d89bf04 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -1503,6 +1503,14 @@ fn get_nested_prop_f64(node: &kdl::KdlNode, child_name: &str, prop_name: &str, d
     default
 }
 
+/// `"344x215"` (also `344,215` / `344 215`) → (w, h) in mm, both positive.
+fn parse_size_mm(s: &str) -> Option<(f64, f64)> {
+    let mut it = s.split(|c: char| c == 'x' || c == 'X' || c == ',' || c.is_whitespace()).filter(|p| !p.is_empty());
+    let w = it.next()?.trim().parse::<f64>().ok()?;
+    let h = it.next()?.trim().parse::<f64>().ok()?;
+    (w > 0.0 && h > 0.0 && it.next().is_none()).then_some((w, h))
+}
+
 fn parse_kdl_config(content: &str) -> Result<Config, String> {
     let doc: kdl::KdlDocument = content.parse().map_err(|e| format!("KDL parse error: {}", e))?;
     
@@ -1724,6 +1732,15 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
                         parsed_scale = Some(num);
                     }
                 }
+                // `size_mm="344x215"`: the panel's real size, overriding
+                // the EDID figure the backend read (TVs and projectors
+                // lie; some panels report nothing). Forwarded into the
+                // wl_output geometry every client sees, so cce-ui's metric
+                // measures against it.
+                let mut parsed_size_mm = None;
+                if let Some(entry) = child.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("size_mm")) {
+                    parsed_size_mm = entry.value().as_string().and_then(parse_size_mm);
+                }
                 let mut parsed_interval = None;
                 if let Some(entry) = child.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("brightness_interval")) {
                     if let Some(num) = entry.value().as_i64() {
@@ -1753,6 +1770,13 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
                             }
                         }
                     }
+                    if parsed_size_mm.is_none() {
+                        if let Some(size_node) = display_children.nodes().iter().find(|n| n.name().value() == "size_mm") {
+                            if let Some(entry) = size_node.entries().first() {
+                                parsed_size_mm = entry.value().as_string().and_then(parse_size_mm);
+                            }
+                        }
+                    }
                     if parsed_interval.is_none() {
                         if let Some(interval_node) = display_children.nodes().iter().find(|n| n.name().value() == "brightness_interval") {
                             if let Some(entry) = interval_node.entries().first() {
@@ -1785,6 +1809,10 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
                 if let Some(num) = parsed_scale {
                     display.insert(format!("scale_{}", name), num);
                 }
+                if let Some((w, h)) = parsed_size_mm {
+                    display.insert(format!("mm_w_{}", name), w);
+                    display.insert(format!("mm_h_{}", name), h);
+                }
                 let interval = parsed_interval.unwrap_or(10);
                 if parsed_interval.is_some() {
                     display.insert(format!("brightness_interval_{}", name), interval as f64);
@@ -2813,6 +2841,7 @@ style {
         let config = parse_kdl_config(content).unwrap();
         assert_eq!(config.display.get("scale_eDP-1"), Some(&2.0));
         assert_eq!(config.display.get("scale_DP-1"), Some(&1.5));
+        assert_eq!(config.display.get("mm_w_eDP-1"), None);
         assert_eq!(config.display.get("brightness_interval_eDP-1"), Some(&10.0));
 
         let up_bind = config.key_bindings.iter().find(|kb| kb.key == "XF86MonBrightnessUp").unwrap();
@@ -2824,6 +2853,29 @@ style {
         assert_eq!(down_bind.command, Some("brightnessctl set 10%-".to_string()));
     }
 
+    #[test]
+    fn test_kdl_display_size_mm_parsing() {
+        let content = r#"
+            output {
+                eDP-1 scale=(f64)2.0 size_mm="344x215"
+                DP-1 {
+                    scale (f64)1.5
+                    size_mm "597 336"
+                }
+                HDMI-A-1 size_mm="bogus"
+            }
+        "#;
+        let config = parse_kdl_config(content).unwrap();
+        assert_eq!(config.display.get("mm_w_eDP-1"), Some(&344.0));
+        assert_eq!(config.display.get("mm_h_eDP-1"), Some(&215.0));
+        assert_eq!(config.display.get("mm_w_DP-1"), Some(&597.0));
+        assert_eq!(config.display.get("mm_h_DP-1"), Some(&336.0));
+        assert_eq!(config.display.get("mm_w_HDMI-A-1"), None);
+        assert_eq!(parse_size_mm("344,215"), Some((344.0, 215.0)));
+        assert_eq!(parse_size_mm("344x0"), None);
+        assert_eq!(parse_size_mm("1x2x3"), None);
+    }
+
     #[test]
     fn test_kdl_window_manager_parsing() {
         let content = r#"
diff --git a/src/server/output.rs b/src/server/output.rs
index e476a5d..d1ed77b 100644
--- a/src/server/output.rs
+++ b/src/server/output.rs
@@ -451,6 +451,30 @@ impl Output {
             .map(|&s| s as f32)
             .unwrap_or((*server).wm.output_scale);
 
+        // The physical size every client's wl_output geometry will carry,
+        // which cce-ui's `units::Metric` measures logical px per mm against.
+        // A configured `size_mm` replaces the EDID figure before the global
+        // exists (wlr_output_layout_add creates it), so no client ever sees
+        // the lie. Logged either way: an output with no size at all leaves
+        // clients on the assumed 96 ppi, and that is worth knowing.
+        let (mut edid_w, mut edid_h) = (0i32, 0i32);
+        ffi::river_wlr_output_get_phys_size(wlr_output, &mut edid_w, &mut edid_h);
+        let configured = (*server).wm.display.get(&format!("mm_w_{}", name))
+            .zip((*server).wm.display.get(&format!("mm_h_{}", name)))
+            .map(|(&w, &h)| (w.round() as i32, h.round() as i32));
+        match configured {
+            Some((w, h)) => {
+                ffi::river_wlr_output_set_phys_size(wlr_output, w, h);
+                log::info!("output {}: physical size {}x{} mm (configured size_mm; EDID said {}x{})", name, w, h, edid_w, edid_h);
+            }
+            None if edid_w > 0 && edid_h > 0 => {
+                log::info!("output {}: physical size {}x{} mm (EDID)", name, edid_w, edid_h);
+            }
+            None => {
+                log::info!("output {}: no physical size — clients assume 96 ppi; set `output {{ {} size_mm=\"WxH\" }}` to measure", name, name);
+            }
+        }
+
         let initial = OutputState {
             state: OutputStateValue::DisabledHard,
             x: 0,
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 1f47736..d1366d9 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -4830,6 +4830,71 @@ impl WindowManager {
                 self.dirty_windowing();
                 "ok\n".to_string()
             }
+            "outputs" => {
+                // One line per output: the figures a client's `units::Metric`
+                // is built from (mode, scale, logical size, physical mm) and
+                // where the mm came from — `configured` (a `size_mm`
+                // override), `measured` (EDID), or `none` (clients fall back
+                // to the assumed 96 ppi). `px_per_mm` is LOGICAL px, the
+                // number cce-ui resolves a `(mm)` length with.
+                let as_json = parts.get(1).copied() == Some("--json");
+                let mut out = String::new();
+                let om = &(*self.server).om;
+                let head = &om.outputs as *const ffi::wl_list as *mut ffi::wl_list;
+                let mut link = om.outputs.next;
+                while link != head {
+                    let output = &*crate::container_of!(link, crate::output::Output, link);
+                    link = (*link).next;
+                    let wlr_output = output.wlr_output;
+                    if wlr_output.is_null() {
+                        continue;
+                    }
+                    let name = std::ffi::CStr::from_ptr(ffi::river_wlr_output_get_name(wlr_output)).to_string_lossy().to_string();
+                    let (mut mm_w, mut mm_h) = (0i32, 0i32);
+                    ffi::river_wlr_output_get_phys_size(wlr_output, &mut mm_w, &mut mm_h);
+                    let source = if self.display.contains_key(&format!("mm_w_{}", name)) {
+                        "configured"
+                    } else if mm_w > 0 && mm_h > 0 {
+                        "measured"
+                    } else {
+                        "none"
+                    };
+                    let st = output.sent;
+                    let enabled = matches!(st.state, crate::output::OutputStateValue::Enabled);
+                    let (pw, ph, refresh) = match st.mode {
+                        crate::output::OutputMode::Standard(m) if !m.is_null() => ((*m).width, (*m).height, (*m).refresh),
+                        crate::output::OutputMode::Custom { width, height, refresh } => (width, height, refresh),
+                        _ => (0, 0, 0),
+                    };
+                    let (lw, lh) = st.dimensions();
+                    let px_per_mm = if mm_w > 0 && mm_h > 0 && lw > 0 && lh > 0 {
+                        0.5 * (lw as f64 / mm_w as f64 + lh as f64 / mm_h as f64)
+                    } else {
+                        0.0
+                    };
+                    if as_json {
+                        out.push_str(&serde_json::json!({
+                            "name": name,
+                            "enabled": enabled,
+                            "x": st.x, "y": st.y,
+                            "mode_w": pw, "mode_h": ph, "refresh_mhz": refresh,
+                            "scale": st.scale,
+                            "logical_w": lw, "logical_h": lh,
+                            "mm_w": mm_w, "mm_h": mm_h,
+                            "px_per_mm": px_per_mm,
+                            "ppi": px_per_mm * 25.4,
+                            "source": source,
+                        }).to_string());
+                        out.push('\n');
+                    } else {
+                        out.push_str(&format!(
+                            "output name={} enabled={} x={} y={} mode={}x{}@{:.3} scale={} logical={}x{} mm={}x{} px_per_mm={:.3} ppi={:.1} source={}\n",
+                            name, enabled, st.x, st.y, pw, ph, refresh as f64 / 1000.0, st.scale, lw, lh, mm_w, mm_h, px_per_mm, px_per_mm * 25.4, source
+                        ));
+                    }
+                }
+                if out.is_empty() { "no outputs\n".to_string() } else { out }
+            }
             "windows" => {
                 // `windows --json` emits one JSON object per line; titles and
                 // app_ids are then properly escaped, unlike the text format.
diff --git a/src/server/wlroots_log_wrapper.c b/src/server/wlroots_log_wrapper.c
index f704e9d..5132429 100644
--- a/src/server/wlroots_log_wrapper.c
+++ b/src/server/wlroots_log_wrapper.c
@@ -177,6 +177,16 @@ struct wlr_output_mode *river_wlr_output_get_current_mode(struct wlr_output *out
 	return output->current_mode;
 }
 
+void river_wlr_output_get_phys_size(struct wlr_output *output, int32_t *width_mm, int32_t *height_mm) {
+	*width_mm = output->phys_width;
+	*height_mm = output->phys_height;
+}
+
+void river_wlr_output_set_phys_size(struct wlr_output *output, int32_t width_mm, int32_t height_mm) {
+	output->phys_width = width_mm;
+	output->phys_height = height_mm;
+}
+
 int32_t river_wlr_output_get_width(struct wlr_output *output) {
 	return output->width;
 }
diff --git a/wrapper.h b/wrapper.h
index ddac0dc..00eae6b 100644
--- a/wrapper.h
+++ b/wrapper.h
@@ -128,6 +128,11 @@ struct wlr_output_mode *river_wlr_output_get_current_mode(struct wlr_output *out
 int32_t river_wlr_output_get_width(struct wlr_output *output);
 int32_t river_wlr_output_get_height(struct wlr_output *output);
 int32_t river_wlr_output_get_refresh(struct wlr_output *output);
+// Physical size in mm as the backend read it from EDID (0 when unknown),
+// and a setter so a configured `size_mm` override reaches every client's
+// wl_output geometry in place of a lying EDID.
+void river_wlr_output_get_phys_size(struct wlr_output *output, int32_t *width_mm, int32_t *height_mm);
+void river_wlr_output_set_phys_size(struct wlr_output *output, int32_t width_mm, int32_t height_mm);
 struct wl_global *river_wlr_output_get_global(struct wlr_output *output);
 
 void *river_wlr_surface_get_data(struct wlr_surface *surface);