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

commitfaebfbebd733a1f6fa3d5edc4cfd3b43a9724639
parent51edba7678
authorLucas Galante <[email protected]>
date2026-08-27 08:12
feat: place-next-cell — launch a window onto the square you asked from

Apps opened from the desktop context menu now land on the grid square
the menu was opened at, keeping the size they had last time and growing
away from whatever already occupies the neighbouring squares, instead of
reappearing wherever they happened to be when last closed.

The existing place-next hint could not carry this. It nudges a POSITION
by a fixed offset for pickers, and it is gated to Floating/Utility —
while the case this is for is a window that reopens filling four
squares. place-next-cell is the grid-aware sibling: the compositor
resolves the square, measures the window's remembered size in squares,
collects the squares other windows occupy, and asks
policy::spawn::place_at_cell which corner to hang it from.

Two details worth keeping. The hint is claimed BEFORE the tiling mode is
judged, so one aimed at this window cannot linger and land on the next
one to open. And matching is loose (the rule Action::Toggle already
uses): a menu knows the COMMAND it ran, while the client picks its own
app_id, and the two agree often but not always.

Shadow-verified end to end through the real menu: with a window at
C1:D1, opening the menu on the square immediately left of it and picking
Files placed Files at -A1:B2 — remembered 3x2 size kept, invocation
square as its top-right corner, clear of its neighbour.

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

 scripts/cce-desktop-menu     |  27 ++++++++--
 src/cce_ctl.rs               |   2 +
 src/server/window.rs         | 125 ++++++++++++++++++++++++++++++++++++++++---
 src/server/window_manager.rs |  58 +++++++++++++++++---
 4 files changed, 193 insertions(+), 19 deletions(-)

diff --git a/scripts/cce-desktop-menu b/scripts/cce-desktop-menu
index 468f70c..fb3be1d 100644
--- a/scripts/cce-desktop-menu
+++ b/scripts/cce-desktop-menu
@@ -7,16 +7,29 @@ CLEAR_CLOUD="$HOME/.local/bin/cce-cloud"
 # Default to empty coordinates if not provided
 x_arg=""
 y_arg=""
+x_val=""
+y_val=""
+
+# Open the app WHERE THE MENU WAS OPENED: the next window matching $1 covers
+# the grid square under the click, keeping the size it had last time and
+# growing away from whatever already occupies the neighbouring squares. You
+# asked for it here, so it opens here — rather than wherever it happened to be
+# when you last closed it. The key is the command name; the compositor matches
+# it loosely against the app_id the client picks.
+place_at() {
+    [[ -n "$x_val" && -n "$y_val" ]] || return 0
+    "$CCE_CTL" place-next-cell "$1" "$x_val" "$y_val" >/dev/null 2>&1 || true
+}
 
 while [[ $# -gt 0 ]]; do
     case "$1" in
         -x|--x-pos)
             # A flag with no value must still consume itself: bare `shift 2`
             # is a no-op when one arg remains, and the loop spins forever.
-            if [[ $# -ge 2 ]]; then x_arg="-x $2"; shift 2; else shift; fi
+            if [[ $# -ge 2 ]]; then x_arg="-x $2"; x_val="$2"; shift 2; else shift; fi
             ;;
         -y|--y-pos)
-            if [[ $# -ge 2 ]]; then y_arg="-y $2"; shift 2; else shift; fi
+            if [[ $# -ge 2 ]]; then y_arg="-y $2"; y_val="$2"; shift 2; else shift; fi
             ;;
         *)
             shift
@@ -64,22 +77,28 @@ case "$btn" in
         if [[ -z "$term" ]] || ! command -v "$term" >/dev/null 2>&1; then
             term=foot
         fi
+        place_at "$term"
         "$term" &
         ;;
     "files")
         # Run files
+        place_at cce-files
         "$HOME"/.local/bin/cce-files &
         ;;
     "data_editor")
         # Run data editor
+        place_at cce-data-editor
         "$HOME"/.local/bin/cce-data-editor &
         ;;
     "apps")
-        # Run applications search/list
-        $CLEAR_CLOUD --mode apps &
+        # Run applications search/list, AT the click: the launcher passes the
+        # position on to whatever it launches, so picking an app from here
+        # opens it on this square too.
+        $CLEAR_CLOUD --mode apps $x_arg $y_arg &
         ;;
     "settings")
         # Run settings interface
+        place_at cce-system-interface
         "$HOME"/.local/bin/cce-system-interface &
         ;;
     "overview")
diff --git a/src/cce_ctl.rs b/src/cce_ctl.rs
index 851b564..e871cd5 100644
--- a/src/cce_ctl.rs
+++ b/src/cce_ctl.rs
@@ -55,6 +55,8 @@ fn usage(name: &str, to_stderr: bool) {
     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("  place-next-cell <app_id> <x> <y> # one-shot: next map covers the grid square at this pos,");
+    print("                              # keeping its size and growing away from its neighbours");
     print("  overview");
     print("  windows [--json]           # list windows; --json emits one JSON object per line");
     print("  status-hide-mode [true|false]");
diff --git a/src/server/window.rs b/src/server/window.rs
index d1d4328..f30d9b1 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -1095,6 +1095,33 @@ impl Window {
     }
 
     unsafe fn try_hint_placement(&mut self) {
+        let app_id = self.get_app_id_string().unwrap_or_default();
+        if app_id.is_empty() {
+            return;
+        }
+        // Claimed before the mode is judged, so a hint aimed at this window
+        // does not linger and land on the next one to open.
+        let Some((hx, hy, cell_anchored)) = (*self.server).wm.take_pending_placement(&app_id)
+        else {
+            return;
+        };
+        if cell_anchored {
+            // TILED IS THE POINT here, unlike the position-only hint below: a
+            // window that reopens filling four squares is exactly the case
+            // this exists for. Only the modes that do not own a position at
+            // all are excluded.
+            if matches!(
+                self.tiling_mode,
+                crate::tiling::TilingMode::Fullscreen
+                    | crate::tiling::TilingMode::Popup
+                    | crate::tiling::TilingMode::Overlay
+                    | crate::tiling::TilingMode::Status
+            ) {
+                return;
+            }
+            self.place_on_invocation_cell(&app_id, hx, hy);
+            return;
+        }
         // Utility included: the hint moves only the POSITION, which a utility
         // window does not own — only its size is the client's.
         if !matches!(
@@ -1103,13 +1130,6 @@ impl Window {
         ) {
             return;
         }
-        let app_id = self.get_app_id_string().unwrap_or_default();
-        if app_id.is_empty() {
-            return;
-        }
-        let Some((hx, hy)) = (*self.server).wm.take_pending_placement(&app_id) else {
-            return;
-        };
 
         let (phys_x, phys_y, vp_w, vp_h) = self.first_enabled_output_box();
 
@@ -1137,6 +1157,97 @@ impl Window {
         );
     }
 
+    /// Place this window on the grid square the user invoked it from, keeping
+    /// its remembered SIZE and growing away from the windows already there
+    /// (`policy::spawn::place_at_cell`).
+    ///
+    /// The size comes from the remembered geometry `try_restore` just applied,
+    /// measured in whole squares: a window last seen filling four squares
+    /// opens filling four squares, at the corner of the invocation square that
+    /// leaves it clear of its neighbours.
+    unsafe fn place_on_invocation_cell(&mut self, app_id: &str, hx: f64, hy: f64) {
+        let wm = &(*self.server).wm;
+        let sp = wm.layout.snap_params();
+        if sp.cell_w <= 0.5 || sp.cell_h <= 0.5 {
+            return;
+        }
+        let (phys_x, phys_y, vp_w, vp_h) = self.first_enabled_output_box();
+        let zoom = wm.desk_zoom.max(0.01);
+        // The hint is a layout point; the grid is in virtual coordinates.
+        let inv_vx = wm.desk_pan_x + (hx - phys_x) / zoom;
+        let inv_vy = wm.desk_pan_y + (hy - phys_y) / zoom;
+        let col = crate::policy::cells::cell_index(inv_vx, sp.cell_w, sp.gap_width);
+        let row = crate::policy::cells::cell_index(inv_vy, sp.cell_h, sp.gap_width);
+
+        // Size in squares, from the geometry `try_restore` left in place.
+        let (vw, vh) = self.mapped_size_hint();
+        let (c0, r0, c1, r1) = crate::policy::cells::window_span(
+            0.0, 0.0, vw, vh, sp.cell_w, sp.cell_h, sp.gap_width,
+        );
+        let (cols, rows) = (c1 - c0 + 1, r1 - r0 + 1);
+
+        // Everything else already on the desktop, in squares. Chrome and the
+        // canvas itself are not obstacles.
+        let mut occupied = Vec::new();
+        for &w in wm.windows.iter() {
+            if w.is_null() || w == (self as *mut Window) || (*w).closed || (*w).minimized {
+                continue;
+            }
+            if !matches!((*w).state, WindowState::Mapped) {
+                continue;
+            }
+            if (*w).is_status_bar() || (*w).is_wallpaper() || (*w).is_grid() {
+                continue;
+            }
+            let (ow, oh) = ((*w).box_geom.width as f64, (*w).box_geom.height as f64);
+            if ow <= 0.0 || oh <= 0.0 {
+                continue;
+            }
+            let (oc0, or0, oc1, or1) = crate::policy::cells::window_span(
+                (*w).virtual_x, (*w).virtual_y, ow, oh, sp.cell_w, sp.cell_h, sp.gap_width,
+            );
+            occupied.push(crate::policy::spawn::CellBlock::new(oc0, or0, oc1, or1));
+        }
+
+        // Visible squares, so a tie between two clear corners goes to the one
+        // on screen.
+        let view = {
+            let (vx0, vy0) = (wm.desk_pan_x, wm.desk_pan_y);
+            let (vx1, vy1) = (vx0 + vp_w / zoom, vy0 + vp_h / zoom);
+            let c0 = crate::policy::cells::cell_index(vx0, sp.cell_w, sp.gap_width);
+            let r0 = crate::policy::cells::cell_index(vy0, sp.cell_h, sp.gap_width);
+            let c1 = crate::policy::cells::cell_index(vx1, sp.cell_w, sp.gap_width);
+            let r1 = crate::policy::cells::cell_index(vy1, sp.cell_h, sp.gap_width);
+            crate::policy::spawn::CellBlock::new(c0, r0, c1, r1)
+        };
+
+        let block = crate::policy::spawn::place_at_cell(col, row, cols, rows, &occupied, Some(view));
+        let (bx, by, bw, bh) = crate::policy::cells::block_rect(
+            block.col0, block.row0, block.col1, block.row1,
+            sp.cell_w, sp.cell_h, sp.gap_width, sp.cell_inset,
+        );
+        self.virtual_x = bx;
+        self.virtual_y = by;
+        // A window that was filling whole squares keeps doing so — it is the
+        // same window, in the same shape, somewhere else. One that was not
+        // keeps its own size and simply starts at the square's corner.
+        if self.tiling_mode == crate::tiling::TilingMode::Tiled {
+            self.box_geom.width = bw.round() as i32;
+            self.box_geom.height = bh.round() as i32;
+            self.wm_requested.dimensions = Some(crate::window::Dimensions {
+                width: bw.round() as u32,
+                height: bh.round() as u32,
+            });
+        }
+        self.hint_placed = true;
+        log::info!(
+            "place-next-cell: {} -> {} ({}x{} squares) at virtual ({:.0}, {:.0})",
+            app_id,
+            crate::policy::cells::span_label(block.col0, block.row0, block.col1, block.row1),
+            cols, rows, bx, by
+        );
+    }
+
     /// Open a session modal in the middle of what the user is looking at,
     /// ignoring wherever it last sat.
     ///
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index e9e103b..113f5dd 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -139,7 +139,11 @@ pub struct WindowManager {
     /// given layout position instead of its remembered spot — widget-spawned
     /// pickers open at the control that launched them. (app_id, screen x/y,
     /// registered-at; entries expire unconsumed after a few seconds.)
-    pub pending_placements: Vec<(String, f64, f64, std::time::Instant)>,
+    /// One-shot placement hints: `(key, x, y, cell_anchored, when)`. `key` is
+    /// matched loosely against a mapping window's app_id (see
+    /// `take_pending_placement`), because a launcher knows the command it ran,
+    /// not the app_id the client will choose.
+    pub pending_placements: Vec<(String, f64, f64, bool, std::time::Instant)>,
     pub shutting_down: bool,
     pub target_desk_pan_x: Option<f64>,
     pub target_desk_pan_y: Option<f64>,
@@ -975,12 +979,30 @@ impl WindowManager {
 
     /// Consume the placement hint for `app_id`, if one was registered in the
     /// last few seconds (stale hints — a spawn that never mapped — are purged).
-    pub fn take_pending_placement(&mut self, app_id: &str) -> Option<(f64, f64)> {
+    /// Claim a pending placement for a window that is mapping.
+    ///
+    /// Matching is exact first, then the loose app_id rule `Action::Toggle`
+    /// already uses (case-insensitive, either side containing the other): a
+    /// menu or launcher knows the COMMAND it ran — `foot`, `cce-files` — while
+    /// the client picks its own app_id, and the two agree often but not
+    /// always. An exact pass first keeps a specific hint from being stolen by
+    /// a loosely-matching one.
+    pub fn take_pending_placement(&mut self, app_id: &str) -> Option<(f64, f64, bool)> {
         const HINT_TTL: std::time::Duration = std::time::Duration::from_secs(10);
-        self.pending_placements.retain(|(_, _, _, at)| at.elapsed() < HINT_TTL);
-        let idx = self.pending_placements.iter().position(|(id, _, _, _)| id == app_id)?;
-        let (_, x, y, _) = self.pending_placements.remove(idx);
-        Some((x, y))
+        self.pending_placements.retain(|(_, _, _, _, at)| at.elapsed() < HINT_TTL);
+        let lower = app_id.to_lowercase();
+        let idx = self
+            .pending_placements
+            .iter()
+            .position(|(id, _, _, _, _)| id == app_id)
+            .or_else(|| {
+                self.pending_placements.iter().position(|(id, _, _, _, _)| {
+                    let k = id.to_lowercase();
+                    !k.is_empty() && (lower.contains(&k) || k.contains(&lower))
+                })
+            })?;
+        let (_, x, y, cell, _) = self.pending_placements.remove(idx);
+        Some((x, y, cell))
     }
 
     pub unsafe fn spawn_restored_windows(&mut self) {
@@ -4360,8 +4382,28 @@ impl WindowManager {
                     _ => return "error: x/y must be numbers\n".to_string(),
                 };
                 let app_id = parts[1].to_string();
-                self.pending_placements.retain(|(id, _, _, _)| id != &app_id);
-                self.pending_placements.push((app_id, x, y, std::time::Instant::now()));
+                self.pending_placements.retain(|(id, _, _, _, _)| id != &app_id);
+                self.pending_placements.push((app_id, x, y, false, std::time::Instant::now()));
+                "ok\n".to_string()
+            }
+            "place-next-cell" => {
+                // place-next-cell <app_id|command> <x> <y>: the next map of a
+                // matching window covers the GRID SQUARE containing this
+                // layout point, keeping its remembered size and growing away
+                // from whatever already occupies the neighbouring squares.
+                // What the desktop menu and the launcher send: you asked for
+                // the window somewhere, so it opens there rather than wherever
+                // it happened to be last time.
+                if parts.len() < 4 {
+                    return "error: usage: place-next-cell <app_id> <x> <y>\n".to_string();
+                }
+                let (x, y) = match (parts[2].parse::<f64>(), parts[3].parse::<f64>()) {
+                    (Ok(x), Ok(y)) => (x, y),
+                    _ => return "error: x/y must be numbers\n".to_string(),
+                };
+                let app_id = parts[1].to_string();
+                self.pending_placements.retain(|(id, _, _, _, _)| id != &app_id);
+                self.pending_placements.push((app_id, x, y, true, std::time::Instant::now()));
                 "ok\n".to_string()
             }
             "pointer-location" => {