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