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

commitec52903ad12958222434c35204d4083d1282433b
parent6ea3caa404
authorLucas Galante <[email protected]>
date2026-08-13 17:24
feat: report and command desktop squares; Tiled drags snap to squares

- seat.rs: a move drag of a Tiled window goes through
  snap::snap_move_tiled (hard snap to the nearest square) instead of the
  magnetic snap_move, so it can only come to rest on whole squares and no
  longer demotes itself to Floating by ending a drag mid-cell. Floating
  windows keep the magnetic snap, which is how they become Tiled.
- window_manager.rs: "windows" / "windows --json" now report each window's
  square as cell=C-9 (or cell=C-9:D-9 for a block).
- window_manager.rs: new "move-window <square> [app_id|id]" IPC command
  places the focused or named window on a named square, carrying the saved
  floating anchor with it so a later un-tile restores at the new square
  rather than the old one. Replies with the resulting cell and virtual pos.
- cce_ctl.rs: usage line for it (the client forwards argv verbatim).

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

 src/cce_ctl.rs               |  1 +
 src/server/seat.rs           | 25 ++++++++++-----
 src/server/window_manager.rs | 74 +++++++++++++++++++++++++++++++++++++++++++-
 3 files changed, 92 insertions(+), 8 deletions(-)

diff --git a/src/cce_ctl.rs b/src/cce_ctl.rs
index f26b883..851b564 100644
--- a/src/cce_ctl.rs
+++ b/src/cce_ctl.rs
@@ -53,6 +53,7 @@ fn usage(name: &str, to_stderr: bool) {
     print("  focus-window <app_id>");
     print("  close-window <app_id|id> [title-substring]  # close a specific window");
     print("  center-window [<app_id>]   # pan focused/named window on-screen; replies x= y= w= h=");
+    print("  move-window <square> [<app_id>] # put focused/named window on a desktop square (e.g. C-9)");
     print("  place-next <app_id> <x> <y> # one-shot: next map of app_id lands near this layout pos");
     print("  overview");
     print("  windows [--json]           # list windows; --json emits one JSON object per line");
diff --git a/src/server/seat.rs b/src/server/seat.rs
index 8d254a9..031e92e 100644
--- a/src/server/seat.rs
+++ b/src/server/seat.rs
@@ -1185,13 +1185,24 @@ impl Seat {
 
                             let vx = op.start_win_virtual_x + virtual_dx;
                             let vy = op.start_win_virtual_y + virtual_dy;
-                            let (vx, vy) = crate::policy::snap::snap_move(
-                                vx,
-                                vy,
-                                (*win).box_geom.width as f64,
-                                (*win).box_geom.height as f64,
-                                &sp,
-                            );
+                            // A Tiled window only ever occupies whole squares,
+                            // so its drag snaps hard to the nearest one. The
+                            // magnetic snap below is for Floating windows,
+                            // which use it to decide whether they land aligned
+                            // (and so become Tiled) at op_end.
+                            let (vx, vy) = if (*self.server).wm.get_mode_for_window(win)
+                                == crate::tiling::TilingMode::Tiled
+                            {
+                                crate::policy::snap::snap_move_tiled(vx, vy, &sp)
+                            } else {
+                                crate::policy::snap::snap_move(
+                                    vx,
+                                    vy,
+                                    (*win).box_geom.width as f64,
+                                    (*win).box_geom.height as f64,
+                                    &sp,
+                                )
+                            };
                             (*win).virtual_x = vx;
                             (*win).virtual_y = vy;
 
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 1c958fe..09e621a 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -3100,6 +3100,65 @@ impl WindowManager {
                 (*target).close();
                 format!("ok {}\n", title)
             }
+            "move-window" => {
+                // move-window <square> [app_id|id] — put a window on a named
+                // desktop square (chess style, e.g. "C-9"). With no app_id the
+                // focused window moves. A Tiled window keeps the SHAPE of its
+                // block and is re-anchored with its top-left on that square;
+                // a Floating window keeps its size.
+                if parts.len() < 2 {
+                    return "error: usage: move-window <square> [app_id|id]\n".to_string();
+                }
+                let sp = self.layout.snap_params();
+                let Some((col, row)) = crate::policy::cells::parse_square(parts[1]) else {
+                    return format!(
+                        "error: '{}' is not a square (expected e.g. A1, C-9, -B2)\n",
+                        parts[1]
+                    );
+                };
+                let target: *mut Window = if parts.len() >= 3 {
+                    self.find_window_by_query(&parts[2..].join(" "))
+                } else if let Some(seat) = self.first_seat() {
+                    match (*seat).focused {
+                        crate::seat::Focus::Window(w) => w,
+                        _ => std::ptr::null_mut(),
+                    }
+                } else {
+                    std::ptr::null_mut()
+                };
+                if target.is_null() {
+                    return "error: window not found\n".to_string();
+                }
+                if (*target).minimized {
+                    (*target).minimized = false;
+                }
+
+                let (x, y, _, _) = crate::policy::cells::square_rect(
+                    col,
+                    row,
+                    sp.cell_size,
+                    sp.gap_width,
+                    sp.cell_inset,
+                );
+                (*target).virtual_x = x;
+                (*target).virtual_y = y;
+                // Saved floating geometry follows the window, so a later
+                // Tiled -> Floating transition restores it at the new square
+                // rather than yanking it back to where it used to live.
+                (*target).saved_floating_virtual_x = x;
+                (*target).saved_floating_virtual_y = y;
+                self.dirty_windowing();
+
+                let cell = crate::policy::cells::window_span_label(
+                    x,
+                    y,
+                    (*target).box_geom.width as f64,
+                    (*target).box_geom.height as f64,
+                    sp.cell_size,
+                    sp.gap_width,
+                );
+                format!("ok cell={} vx={:.1} vy={:.1}\n", cell, x, y)
+            }
             "center-window" | "bring-window" => {
                 // Pan the desktop so the target window (given app_id/id, or the
                 // focused window if omitted) is centered in the output, then focus
@@ -3295,10 +3354,21 @@ impl WindowManager {
                 }
 
                 let mut out = String::new();
+                let sp = self.layout.snap_params();
                 for &w in self.windows.iter() {
                     if !w.is_null() && !(*w).closed && !matches!((*w).state, crate::window::WindowState::Closing | crate::window::WindowState::Init) {
                         let app_id = (*w).get_app_id_string().unwrap_or_default();
                         let title = (*w).get_title_string().unwrap_or_default();
+                        // Which desktop square(s) the window sits on, chess
+                        // style: "C-9" for one, "C-9:D-9" for a block.
+                        let cell = crate::policy::cells::window_span_label(
+                            (*w).virtual_x,
+                            (*w).virtual_y,
+                            (*w).box_geom.width as f64,
+                            (*w).box_geom.height as f64,
+                            sp.cell_size,
+                            sp.gap_width,
+                        );
                         if as_json {
                             out.push_str(&serde_json::json!({
                                 "id": (*w).ref_key.index,
@@ -3311,6 +3381,7 @@ impl WindowManager {
                                 "h": (*w).box_geom.height,
                                 "vx": (*w).virtual_x,
                                 "vy": (*w).virtual_y,
+                                "cell": cell,
                                 "minimized": (*w).minimized,
                                 "has_parent": (*w).has_parent,
                                 "focused": w == focused_window,
@@ -3319,7 +3390,7 @@ impl WindowManager {
                             out.push('\n');
                         } else {
                             out.push_str(&format!(
-                                "window id={} app_id={} title=\"{}\" mode={} x={} y={} w={} h={} vx={:.1} vy={:.1} minimized={} has_parent={} focused={} ssd={}\n",
+                                "window id={} app_id={} title=\"{}\" mode={} x={} y={} w={} h={} vx={:.1} vy={:.1} cell={} minimized={} has_parent={} focused={} ssd={}\n",
                                 (*w).ref_key.index,
                                 app_id,
                                 title,
@@ -3330,6 +3401,7 @@ impl WindowManager {
                                 (*w).box_geom.height,
                                 (*w).virtual_x,
                                 (*w).virtual_y,
+                                cell,
                                 (*w).minimized,
                                 (*w).has_parent,
                                 w == focused_window,