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

commitaae9f5938bca7c5311bfd132bf5d89727b188608
parent8c04fed225
authorLucas Galante <[email protected]>
date2026-08-21 18:50
feat: grid_cell_width / grid_cell_height config keys

Wire the policy crate's per-axis cell sizes through the mechanism:

- config: Layout.desktop_cell_width/height replace desktop_grid_scale.
  New desktop keys grid_cell_width / grid_cell_height; the legacy
  grid_cell_size (and desktop_grid_scale alias) still parses and sets
  both axes, with the specific keys winning when present. Same
  precedence over the config-value IPC path.
- window_manager: ActionCtx gets both grid periods; grid-client patch
  bounds align per axis; arrange params and the cells:: calls
  (move-window, windows --json labels) pass both sizes.
- seat: interactive resize snaps width against the x-axis view and
  height against the y-axis view.
- output: the grid lattice, backdrop, bevel rings, and overview labels
  draw with per-axis periods and cell extents; label sizing and the
  line-relief roll budget key off the smaller dimension.

Shadow-verified at 512x256: lattice renders rectangles on 528x272
periods, move-window B-2 lands at (532, -540), tiling spans 2x2 cells
as 1032x520, keyed pans step 528 horizontally / 272 vertically.

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

 src/server/config.rs         | 46 ++++++++++++++++++++++++++++++++++++++-----
 src/server/output.rs         | 28 +++++++++++++++-----------
 src/server/seat.rs           |  4 ++--
 src/server/window_manager.rs | 47 +++++++++++++++++++++++++++++++-------------
 4 files changed, 92 insertions(+), 33 deletions(-)

diff --git a/src/server/config.rs b/src/server/config.rs
index fa740d8..59fe588 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -52,7 +52,11 @@ pub struct Layout {
     pub transparency_opacity: f32,
     pub window_opacity: bool,
     pub desktop_cell_color: [f32; 4],
-    pub desktop_grid_scale: f64,
+    /// Desktop grid cell width/height in virtual units (each axis's period
+    /// is cell + gap). Config: `grid_cell_width` / `grid_cell_height`, with
+    /// the legacy `grid_cell_size` setting both.
+    pub desktop_cell_width: f64,
+    pub desktop_cell_height: f64,
     pub desktop_gap_width: i32,
     /// Grid-line lip width in logical px; None = follow the DE-wide relief
     /// material (bevel_thickness clamped to the rail), 0 = no lip.
@@ -121,7 +125,8 @@ impl Layout {
         BackgroundSpec::Grid(GridSpec {
             gap_color: Rgba(parse_hex_color_rgba(&self.desktop_gap_color)),
             cell_color: Rgba(self.desktop_cell_color),
-            cell_size: self.desktop_grid_scale,
+            cell_w: self.desktop_cell_width,
+            cell_h: self.desktop_cell_height,
             gap_width: self.desktop_gap_width as f64,
             // Cells inherit the window backplate radius: a tiled window's
             // content covers exactly the visible cell box, so its arc sits
@@ -136,7 +141,8 @@ impl Layout {
     /// disabled) makes every snap function a no-op.
     pub fn snap_params(&self) -> crate::policy::snap::SnapParams {
         crate::policy::snap::SnapParams {
-            cell_size: self.desktop_grid_scale,
+            cell_w: self.desktop_cell_width,
+            cell_h: self.desktop_cell_height,
             gap_width: self.desktop_gap_width as f64,
             cell_inset: self.desktop_cell_fade_inset as f64,
             threshold: if self.desktop_snap { self.desktop_snap_threshold } else { 0.0 },
@@ -185,7 +191,8 @@ impl Default for Layout {
             transparency_opacity: 0.9,
             window_opacity: true,
             desktop_cell_color: [0.05, 0.05, 0.05, 0.05],
-            desktop_grid_scale: 100.0,
+            desktop_cell_width: 100.0,
+            desktop_cell_height: 100.0,
             desktop_gap_width: 1,
             desktop_line_relief: None,
             desktop_cell_fade_inset: 0,
@@ -397,6 +404,12 @@ pub struct SurfaceConfig {
     pub desktop_cell_color: String,
     #[serde(default = "default_desktop_grid_scale")]
     pub desktop_grid_scale: i64,
+    /// Per-axis cell sizes; None falls back to `desktop_grid_scale`
+    /// (the legacy square `grid_cell_size`).
+    #[serde(default)]
+    pub grid_cell_width: Option<i64>,
+    #[serde(default)]
+    pub grid_cell_height: Option<i64>,
     #[serde(default = "default_desktop_gap_width")]
     pub desktop_gap_width: i64,
     /// Negative = unset (follow the DE-wide relief material).
@@ -507,6 +520,8 @@ impl Default for SurfaceConfig {
             desktop_gap_color: default_desktop_gap_color(),
             desktop_cell_color: default_desktop_cell_color(),
             desktop_grid_scale: default_desktop_grid_scale(),
+            grid_cell_width: None,
+            grid_cell_height: None,
             desktop_gap_width: default_desktop_gap_width(),
             desktop_line_relief: default_desktop_line_relief(),
             desktop_cell_fade_inset: default_desktop_cell_fade_inset(),
@@ -1794,6 +1809,16 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
                                             surface.desktop_grid_scale = val;
                                         }
                                     }
+                                    "grid_cell_width" => {
+                                        if let Some(val) = entry.value().as_i64() {
+                                            surface.grid_cell_width = Some(val);
+                                        }
+                                    }
+                                    "grid_cell_height" => {
+                                        if let Some(val) = entry.value().as_i64() {
+                                            surface.grid_cell_height = Some(val);
+                                        }
+                                    }
                                     "snap" => {
                                         if let Some(val) = entry.value().as_bool() {
                                             surface.desktop_snap = val;
@@ -2017,6 +2042,14 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
             surface.desktop_gap_color = get_child_arg_string(node, "desktop_gap_color", &default_desktop_gap_color());
             surface.desktop_cell_color = get_child_arg_string(node, "desktop_cell_color", &default_desktop_cell_color());
             surface.desktop_grid_scale = get_child_arg_i64(node, "grid_cell_size", get_child_arg_i64(node, "desktop_grid_scale", default_desktop_grid_scale()));
+            surface.grid_cell_width = match get_child_arg_i64(node, "grid_cell_width", i64::MIN) {
+                i64::MIN => None,
+                v => Some(v),
+            };
+            surface.grid_cell_height = match get_child_arg_i64(node, "grid_cell_height", i64::MIN) {
+                i64::MIN => None,
+                v => Some(v),
+            };
             surface.desktop_gap_width = get_child_arg_i64(node, "desktop_gap_width", default_desktop_gap_width());
             surface.desktop_cell_fade_inset = get_child_arg_i64(node, "desktop_cell_fade_inset", default_desktop_cell_fade_inset());
             surface.desktop_grid_fade_mode = get_child_arg_string(node, "grid_fade_mode", &default_desktop_grid_fade_mode());
@@ -2193,7 +2226,10 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
     state.layout.background_a = 0xFFFFFFFF;
 
     state.layout.desktop_cell_color = parse_hex_color_rgba(&config.surface.desktop_cell_color);
-    state.layout.desktop_grid_scale = config.surface.desktop_grid_scale as f64;
+    state.layout.desktop_cell_width =
+        config.surface.grid_cell_width.unwrap_or(config.surface.desktop_grid_scale) as f64;
+    state.layout.desktop_cell_height =
+        config.surface.grid_cell_height.unwrap_or(config.surface.desktop_grid_scale) as f64;
     state.layout.desktop_snap = config.surface.desktop_snap;
     state.layout.overview_anim = if config.surface.desktop_overview_ramp.is_empty() {
         None
diff --git a/src/server/output.rs b/src/server/output.rs
index 23b8cbf..b30648e 100644
--- a/src/server/output.rs
+++ b/src/server/output.rs
@@ -953,7 +953,7 @@ impl Output {
                         // and a tiled window's (widened) arc must land on
                         // the cell's arc.
                         let cell_radius = crate::window::widen_corner_radius(
-                            cells.corner_radius_px, cells.cell_px, cells.cell_px,
+                            cells.corner_radius_px, cells.cell_w_px, cells.cell_h_px,
                         );
                         // The backplate-edge roll (mirroring cce-grid): the
                         // bevel-width knob clamped to a fraction of the
@@ -964,14 +964,17 @@ impl Output {
                         // width outright (0 = no lip). The ring expands the
                         // cell box by the roll, inner edge concentric with
                         // the cell arc.
-                        let gap_px = (frame.period_px_exact - cells.cell_px as f64).max(0.0);
+                        let gap_px = (frame.period_px_exact_x - cells.cell_w_px as f64)
+                            .min(frame.period_px_exact_y - cells.cell_h_px as f64)
+                            .max(0.0);
                         let roll = layout
                             .desktop_line_relief
                             .map(|v| v * zoom)
                             .unwrap_or_else(|| (layout.bevel_thickness as f64 * zoom).min(gap_px * 0.25))
                             .max(0.0);
                         let hg = roll.round() as i32;
-                        let ring_px = cells.cell_px + 2 * hg;
+                        let ring_w_px = cells.cell_w_px + 2 * hg;
+                        let ring_h_px = cells.cell_h_px + 2 * hg;
                         let ring_radius = cell_radius + hg;
                         // Cell positions from the EXACT period, rounded per
                         // cell: a rounded-period spacing drifts from the
@@ -979,12 +982,12 @@ impl Output {
                         // grid visibly slides against window edges when
                         // panning).
                         for col in 0..=cells.cols {
-                            let rel_x = (col as f64 * frame.period_px_exact).round() as i32;
+                            let rel_x = (col as f64 * frame.period_px_exact_x).round() as i32;
                             for row in 0..=cells.rows {
-                                let rel_y = (row as f64 * frame.period_px_exact).round() as i32;
-                                get_rect(cells.cell_px, cells.cell_px, cells.color.0.as_ptr(), rel_x, rel_y, cell_radius, inset_scaled);
+                                let rel_y = (row as f64 * frame.period_px_exact_y).round() as i32;
+                                get_rect(cells.cell_w_px, cells.cell_h_px, cells.color.0.as_ptr(), rel_x, rel_y, cell_radius, inset_scaled);
                                 if bevel_on && hg > 0 {
-                                    get_bevel(ring_px, ring_px, rel_x - hg, rel_y - hg, ring_radius, roll as f32);
+                                    get_bevel(ring_w_px, ring_h_px, rel_x - hg, rel_y - hg, ring_radius, roll as f32);
                                 }
                             }
                         }
@@ -1061,8 +1064,9 @@ impl Output {
         // A fixed fraction of the on-screen cell, clamped so labels stay
         // readable when zoomed far out and don't swell into billboards when
         // near. Below the floor there is no room for glyphs at all.
-        let px = ((cells.cell_px as f32) * 0.16).clamp(9.0, 40.0);
-        if px * 3.0 > cells.cell_px as f32 {
+        let min_cell_px = cells.cell_w_px.min(cells.cell_h_px);
+        let px = ((min_cell_px as f32) * 0.16).clamp(9.0, 40.0);
+        if px * 3.0 > min_cell_px as f32 {
             self.disable_cell_labels();
             return;
         }
@@ -1078,12 +1082,12 @@ impl Output {
             }
         }
 
-        let inset = (cells.cell_px as f64 * 0.06).round() as i32;
+        let inset = (min_cell_px as f64 * 0.06).round() as i32;
         let mut idx = 0usize;
         for col in 0..=cells.cols {
-            let rel_x = (col as f64 * frame.period_px_exact).round() as i32;
+            let rel_x = (col as f64 * frame.period_px_exact_x).round() as i32;
             for row in 0..=cells.rows {
-                let rel_y = (row as f64 * frame.period_px_exact).round() as i32;
+                let rel_y = (row as f64 * frame.period_px_exact_y).round() as i32;
                 let text = crate::policy::cells::square_label(
                     frame.first_col + col,
                     frame.first_row + row,
diff --git a/src/server/seat.rs b/src/server/seat.rs
index 8bf057d..f664a59 100644
--- a/src/server/seat.rs
+++ b/src/server/seat.rs
@@ -1320,11 +1320,11 @@ impl Seat {
                         // through snap::resize_axis.
                         let new_w = crate::policy::snap::resize_axis(
                             op.start_win_virtual_x, op.start_win_w as f64, virtual_dx,
-                            edges.left, edges.right, 50.0, &sp,
+                            edges.left, edges.right, 50.0, &sp.x(),
                         ) as u32;
                         let new_h = crate::policy::snap::resize_axis(
                             op.start_win_virtual_y, op.start_win_h as f64, virtual_dy,
-                            edges.top, edges.bottom, 50.0, &sp,
+                            edges.top, edges.bottom, 50.0, &sp.y(),
                         ) as u32;
 
                         (*win).virtual_x = vx;
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index a93dd44..549710d 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -1274,7 +1274,9 @@ impl WindowManager {
             cursor_y,
             hovered,
             focused,
-            grid_period: self.layout.desktop_grid_scale.max(5.0)
+            grid_period_x: self.layout.desktop_cell_width.max(5.0)
+                + self.layout.desktop_gap_width.max(0) as f64,
+            grid_period_y: self.layout.desktop_cell_height.max(5.0)
                 + self.layout.desktop_gap_width.max(0) as f64,
             windows,
         }
@@ -1976,11 +1978,11 @@ impl WindowManager {
                         let sp = self.layout.snap_params().for_zoom(self.desk_zoom);
                         let new_w = crate::policy::snap::resize_axis(
                             op.start_win_virtual_x, op.start_win_w as f64, virtual_dx,
-                            edges.left, edges.right, 50.0, &sp,
+                            edges.left, edges.right, 50.0, &sp.x(),
                         ) as u32;
                         let new_h = crate::policy::snap::resize_axis(
                             op.start_win_virtual_y, op.start_win_h as f64, virtual_dy,
-                            edges.top, edges.bottom, 50.0, &sp,
+                            edges.top, edges.bottom, 50.0, &sp.y(),
                         ) as u32;
                         return Some((new_w, new_h));
                     }
@@ -2122,7 +2124,9 @@ impl WindowManager {
         while zoom * out_scale / q > 2.0 && q < q_max {
             q = (q * 2.0).min(q_max);
         }
-        let period = self.layout.desktop_grid_scale
+        let period_x = self.layout.desktop_cell_width
+            + (self.layout.desktop_gap_width as f64).max(0.0);
+        let period_y = self.layout.desktop_cell_height
             + (self.layout.desktop_gap_width as f64).max(0.0);
 
         // Target viewport (virtual units), for the union coverage below.
@@ -2269,10 +2273,10 @@ impl WindowManager {
             }
             let m = (((max_buf / q) - uw) / (2.0 * uw)).clamp(0.0, 0.5)
                 .min((((max_buf / q) - uh) / (2.0 * uh)).clamp(0.0, 0.5));
-            let x0 = ((ux0 - m * uw) / period).floor() * period;
-            let y0 = ((uy0 - m * uh) / period).floor() * period;
-            let x1 = ((ux1 + m * uw) / period).ceil() * period;
-            let y1 = ((uy1 + m * uh) / period).ceil() * period;
+            let x0 = ((ux0 - m * uw) / period_x).floor() * period_x;
+            let y0 = ((uy0 - m * uh) / period_y).floor() * period_y;
+            let x1 = ((ux1 + m * uw) / period_x).ceil() * period_x;
+            let y1 = ((uy1 + m * uh) / period_y).ceil() * period_y;
             let patch = crate::policy::api::GridPatch {
                 x: x0,
                 y: y0,
@@ -2451,7 +2455,8 @@ impl WindowManager {
                 gap_right: self.layout.gap_right,
                 gap_top: self.layout.gap_top,
                 cloud_position_default: self.layout.cloud_position_default,
-                desktop_grid_scale: self.layout.desktop_grid_scale,
+                desktop_cell_w: self.layout.desktop_cell_width,
+                desktop_cell_h: self.layout.desktop_cell_height,
                 desktop_gap_width: self.layout.desktop_gap_width as f64,
                 desktop_cell_inset: self.layout.desktop_cell_fade_inset as f64,
             },
@@ -3770,7 +3775,8 @@ impl WindowManager {
                 let (x, y, _, _) = crate::policy::cells::square_rect(
                     col,
                     row,
-                    sp.cell_size,
+                    sp.cell_w,
+                    sp.cell_h,
                     sp.gap_width,
                     sp.cell_inset,
                 );
@@ -3788,7 +3794,8 @@ impl WindowManager {
                     y,
                     (*target).box_geom.width as f64,
                     (*target).box_geom.height as f64,
-                    sp.cell_size,
+                    sp.cell_w,
+                    sp.cell_h,
                     sp.gap_width,
                 );
                 format!("ok cell={} vx={:.1} vy={:.1}\n", cell, x, y)
@@ -4009,7 +4016,8 @@ impl WindowManager {
                             (*w).virtual_y,
                             (*w).box_geom.width as f64,
                             (*w).box_geom.height as f64,
-                            sp.cell_size,
+                            sp.cell_w,
+                            sp.cell_h,
                             sp.gap_width,
                         );
                         if as_json {
@@ -4086,9 +4094,20 @@ impl WindowManager {
                     "desktop_cell_color" => {
                         self.layout.desktop_cell_color = crate::config::parse_hex_color_rgba(val);
                     }
-                    "desktop_grid_scale" => {
+                    "desktop_grid_scale" | "grid_cell_size" => {
+                        if let Ok(v) = val.parse::<f64>() {
+                            self.layout.desktop_cell_width = v;
+                            self.layout.desktop_cell_height = v;
+                        }
+                    }
+                    "grid_cell_width" => {
+                        if let Ok(v) = val.parse::<f64>() {
+                            self.layout.desktop_cell_width = v;
+                        }
+                    }
+                    "grid_cell_height" => {
                         if let Ok(v) = val.parse::<f64>() {
-                            self.layout.desktop_grid_scale = v;
+                            self.layout.desktop_cell_height = v;
                         }
                     }
                     "desktop_gap_width" => {