Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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);