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

commit9e5b92ade25c3a8c049781cf18d5693253474588
parentd9469bade5
authorLucas Galante <[email protected]>
date2026-07-31 12:30
feat: place-next — one-shot placement hint for widget-spawned windows

New IPC command `place-next <app_id> <x> <y>`: the next map of a
floating toplevel with that app_id lands its top-left just below-right
of the given layout position (clamped on-screen) instead of its
remembered spot, keeping the remembered size. The hint is one-shot,
expires after 10s, and suppresses the center_on_spawn viewport pan —
the window opens under the user's pointer, so there is nothing to pan
to. Sent by cce-ui's ColorSelector with the pointer location just
before spawning cce-color-editor, so the picker opens at the control
that launched it.

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

 src/cce_ctl.rs               |  1 +
 src/server/seat.rs           |  4 ++-
 src/server/window.rs         | 66 ++++++++++++++++++++++++++++++++++++++++++++
 src/server/window_manager.rs | 35 +++++++++++++++++++++++
 4 files changed, 105 insertions(+), 1 deletion(-)

diff --git a/src/cce_ctl.rs b/src/cce_ctl.rs
index a2fc5fd..99f9e7b 100644
--- a/src/cce_ctl.rs
+++ b/src/cce_ctl.rs
@@ -54,6 +54,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("  place-next <app_id> <x> <y> # one-shot: next map of app_id lands near this layout pos");
     print("  expose");
     print("  windows [--json]           # list windows; --json emits one JSON object per line");
     print("  status-hide-mode [true|false]");
diff --git a/src/server/seat.rs b/src/server/seat.rs
index 4551b03..9003a3e 100644
--- a/src/server/seat.rs
+++ b/src/server/seat.rs
@@ -447,7 +447,9 @@ impl Seat {
                     // spawn even though `restored` is set — it only borrowed its old geometry
                     // from `last_window_states`. Focus moving between windows that were
                     // already up still pans either way; the key is about spawning.
-                    let spawn_pan = !(*window).session_restored && (*self.server).wm.center_on_spawn;
+                    let spawn_pan = !(*window).session_restored
+                        && !(*window).hint_placed
+                        && (*self.server).wm.center_on_spawn;
                     let should_pan = (!is_new || spawn_pan) && !is_cce_cloud;
                     if should_pan {
                         let outputs_list = &mut (*self.server).om.outputs as *mut ffi::wl_list as *mut WlList;
diff --git a/src/server/window.rs b/src/server/window.rs
index 7c91a31..e202586 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -371,6 +371,10 @@ pub struct Window {
     pub mode_locked: bool,
     pub is_new: bool,
     pub restored: bool,
+    /// Placed at map by a one-shot `place-next` hint (widget-spawned picker
+    /// opening at its control): suppresses the spawn viewport pan — the
+    /// window is already where the user is looking.
+    pub hint_placed: bool,
     /// True only when the restored geometry came out of the startup restore queue
     /// (`state.json`'s window list). A window reopened later in the session matches
     /// `last_window_states` instead and leaves this false, so it still counts as a
@@ -589,6 +593,7 @@ impl Window {
             mode_locked: false,
             is_new: true,
             restored: false,
+            hint_placed: false,
             session_restored: false,
             restored_focused: false,
             closed: false,
@@ -931,6 +936,66 @@ impl Window {
         }
     }
 
+    /// Apply a one-shot `place-next` hint: land the window's top-left just
+    /// below-right of the hinted layout position (the control that spawned
+    /// it), clamped to the output so it stays fully on-screen. Runs after
+    /// `try_restore` so the remembered SIZE is kept — only the position is
+    /// overridden — and marks `hint_placed` so the spawn viewport pan is
+    /// skipped (the window is already under the user's pointer).
+    unsafe fn try_hint_placement(&mut self) {
+        if self.tiling_mode != crate::tiling::TilingMode::Floating {
+            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;
+        };
+
+        // First enabled output's layout box (the center-window fallback).
+        let (mut vp_w, mut vp_h) = (1920.0_f64, 1080.0_f64);
+        let (mut phys_x, mut phys_y) = (0i32, 0i32);
+        let outputs_list = &mut (*self.server).om.outputs as *mut ffi::wl_list as *mut WlList;
+        let mut curr_out = (*outputs_list).next;
+        while curr_out != outputs_list {
+            let output = crate::container_of!(curr_out, crate::output::Output, link);
+            if (*output).sent.state == crate::output::OutputStateValue::Enabled {
+                let wlr_box = (*output).sent.box_layout();
+                vp_w = wlr_box.width as f64;
+                vp_h = wlr_box.height as f64;
+                phys_x = wlr_box.x;
+                phys_y = wlr_box.y;
+                break;
+            }
+            curr_out = (*curr_out).next;
+        }
+
+        let wm = &(*self.server).wm;
+        let zoom = wm.desk_zoom.max(0.01);
+        let w = if self.box_geom.width > 0 { self.box_geom.width as f64 } else { 400.0 } * zoom;
+        let h = if self.box_geom.height > 0 { self.box_geom.height as f64 } else { 400.0 } * zoom;
+
+        const OFFSET: f64 = 12.0; // context-menu-style drop below-right of the control
+        const MARGIN: f64 = 8.0;
+        let sx = (hx + OFFSET)
+            .min(phys_x as f64 + vp_w - w - MARGIN)
+            .max(phys_x as f64 + MARGIN);
+        let sy = (hy + OFFSET)
+            .min(phys_y as f64 + vp_h - h - MARGIN)
+            .max(phys_y as f64 + MARGIN);
+
+        // screen = phys + (virtual - desk_pan) * zoom  →  invert for virtual.
+        self.virtual_x = wm.desk_pan_x + (sx - phys_x as f64) / zoom;
+        self.virtual_y = wm.desk_pan_y + (sy - phys_y as f64) / zoom;
+        self.hint_placed = true;
+        log::info!(
+            "place-next hint applied: app_id={} screen=({:.0},{:.0}) virtual=({:.1},{:.1})",
+            app_id, sx, sy, self.virtual_x, self.virtual_y
+        );
+    }
+
     pub unsafe fn map(&mut self) -> Result<(), &'static str> {
         log::debug!("window '{:?}' mapped", self.get_title());
         assert!(!matches!(self.impl_type, WindowImpl::Destroying));
@@ -938,6 +1003,7 @@ impl Window {
         self.state = WindowState::Mapped;
 
         self.try_restore();
+        self.try_hint_placement();
 
         let surface = self.root_surface();
         if !surface.is_null() {
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index b0de0ac..824c501 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -112,6 +112,12 @@ pub struct WindowManager {
     pub injected_key_mods: u32,
     pub restore_queue: Vec<SavedWindowState>,
     pub last_window_states: Vec<SavedWindowState>,
+    /// One-shot placement hints (`place-next <app_id> <x> <y>` over IPC):
+    /// the next map of a floating toplevel with this app_id lands near the
+    /// 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)>,
     pub shutting_down: bool,
     pub target_desk_pan_x: Option<f64>,
     pub target_desk_pan_y: Option<f64>,
@@ -211,6 +217,7 @@ impl WindowManager {
         self.global_layout = crate::tiling::TilingMode::Cascade;
         self.restore_queue = Vec::new();
         self.last_window_states = Vec::new();
+        self.pending_placements = Vec::new();
         self.shutting_down = false;
         self.layout = crate::config::Layout::default();
         self.output_scale = 1.0;
@@ -719,6 +726,16 @@ impl WindowManager {
         None
     }
 
+    /// 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)> {
+        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))
+    }
+
     pub unsafe fn spawn_restored_windows(&mut self) {
         log::info!("Spawning restored windows. Total: {}", self.restore_queue.len());
         let restored = self.restore_queue.clone();
@@ -3247,6 +3264,24 @@ impl WindowManager {
                     "error: invalid dy or dx\n".to_string()
                 }
             }
+            "place-next" => {
+                // place-next <app_id> <x> <y>: one-shot hint — the next map of
+                // a floating toplevel with this app_id lands near this layout
+                // position (top-left, clamped on-screen) instead of its
+                // remembered spot. Widgets send it with the pointer location
+                // just before spawning a picker so it opens at the control.
+                if parts.len() < 4 {
+                    return "error: usage: place-next <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, std::time::Instant::now()));
+                "ok\n".to_string()
+            }
             "pointer-location" => {
                 let mut reply = "error: no seat\n".to_string();
                 let seats_list = &mut (*self.server).input_manager.seats as *mut ffi::wl_list as *mut WlList;