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

commitd21524d3734c42a750fbc556a6f981c0c3b92d9f
parent82ed1a242d
authorLucas Galante <[email protected]>
date2026-07-07 14:36
feat(ipc): add center-window command to pan a window on-screen

Navigating to an off-screen window over IPC was awkward: focus-window
focuses and raises but never adjusts the desktop pan, so the target
stays off the visible viewport, and callers had to guess pan-by/pan-to
amounts to bring it into view for a screenshot.

Add `center-window [<app_id|id>]`: resolves the given window (or the
focused one if omitted), pans the desktop so its center lands at the
output center (reusing the View1-4 centering math, set directly so
there's no animation), focuses and raises it, and replies with the
window's resulting on-screen box — `ok x= y= w= h=` — which is exactly
the geometry `grim -g "X,Y WxH"` expects.

Factor the window-matching logic (numeric id, then exact/substring
app_id) out of focus-window into a shared find_window_by_query helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

 src/cce_ctl.rs               |   1 +
 src/server/window_manager.rs | 150 ++++++++++++++++++++++++++++++++-----------
 2 files changed, 113 insertions(+), 38 deletions(-)

diff --git a/src/cce_ctl.rs b/src/cce_ctl.rs
index 191b9cc..9c483a3 100644
--- a/src/cce_ctl.rs
+++ b/src/cce_ctl.rs
@@ -50,6 +50,7 @@ fn usage(name: &str, to_stderr: bool) {
     print("  overlay-left");
     print("  overlay-right");
     print("  focus-window <app_id>");
+    print("  center-window [<app_id>]   # pan focused/named window on-screen; replies x= y= w= h=");
     print("  expose");
     print("  windows");
     print("  exit");
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 68c6d3d..b0cae0d 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -2702,6 +2702,49 @@ fn get_closest_tag(x: f64, y: f64) -> i32 {
         }
     }
 
+    /// Resolve a window by a query string: an exact numeric window id first,
+    /// otherwise a case-insensitive app_id match (exact beats substring).
+    /// Returns null if nothing mapped matches. Shared by focus-window /
+    /// center-window.
+    pub unsafe fn find_window_by_query(&self, query: &str) -> *mut Window {
+        let query = query.to_lowercase();
+
+        if let Ok(id) = query.parse::<u32>() {
+            for &w in self.windows.iter() {
+                if !w.is_null() && !(*w).closed
+                    && matches!((*w).state, crate::window::WindowState::Mapped)
+                    && (*w).ref_key.index == id
+                {
+                    return w;
+                }
+            }
+        }
+
+        let mut best_target: *mut Window = std::ptr::null_mut();
+        let mut best_score = 0;
+        for &w in self.windows.iter() {
+            if !w.is_null() && !(*w).closed
+                && matches!((*w).state, crate::window::WindowState::Mapped)
+            {
+                if let Some(aid) = (*w).get_app_id_string() {
+                    let aid_lower = aid.to_lowercase();
+                    let score = if aid_lower == query {
+                        100
+                    } else if aid_lower.contains(&query) {
+                        50
+                    } else {
+                        0
+                    };
+                    if score > best_score {
+                        best_score = score;
+                        best_target = w;
+                    }
+                }
+            }
+        }
+        best_target
+    }
+
     pub unsafe fn process_ipc_command(&mut self, cmd: &str) -> String {
         let parts: Vec<&str> = cmd.split_whitespace().collect();
         if parts.is_empty() {
@@ -2965,45 +3008,8 @@ fn get_closest_tag(x: f64, y: f64) -> i32 {
             }
             "focus-window" => {
                 if parts.len() < 2 { return "error: missing app_id/id\n".to_string(); }
-                let query = parts[1..].join(" ").to_lowercase();
                 if let Some(seat) = self.first_seat() {
-                    let mut best_target: *mut Window = std::ptr::null_mut();
-                    let mut best_score = 0;
-
-                    // Try to match by numerical window index first
-                    if let Ok(id) = query.parse::<u32>() {
-                        for &w in self.windows.iter() {
-                            if !w.is_null() && !(*w).closed && matches!((*w).state, crate::window::WindowState::Mapped) && (*w).ref_key.index == id {
-                                best_target = w;
-                                break;
-                            }
-                        }
-                    }
-
-                    // Fallback to matching by app_id
-                    if best_target.is_null() {
-                        for &w in self.windows.iter() {
-                            if !w.is_null() && !(*w).closed && matches!((*w).state, crate::window::WindowState::Mapped) {
-                                let aid = (*w).get_app_id_string();
-                                
-                                let mut score = 0;
-                                if let Some(ref aid_str) = aid {
-                                    let aid_lower = aid_str.to_lowercase();
-                                    if aid_lower == query {
-                                        score = score.max(100);
-                                    } else if aid_lower.contains(&query) {
-                                        score = score.max(50);
-                                    }
-                                }
-
-                                if score > best_score {
-                                    best_score = score;
-                                    best_target = w;
-                                }
-                            }
-                        }
-                    }
-
+                    let best_target = self.find_window_by_query(&parts[1..].join(" "));
                     if !best_target.is_null() {
                         if (*best_target).minimized {
                             (*best_target).minimized = false;
@@ -3019,6 +3025,74 @@ fn get_closest_tag(x: f64, y: f64) -> i32 {
                     "error: no seat found\n".to_string()
                 }
             }
+            "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
+                // and raise it. Replies with the window's resulting on-screen box so
+                // the caller can screenshot it directly (grim -g "X,Y WxH").
+                let target: *mut Window = if parts.len() >= 2 {
+                    self.find_window_by_query(&parts[1..].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;
+                }
+
+                // Enabled output's origin + size (fall back to 1920x1080 @ 0,0).
+                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 w = if (*target).box_geom.width > 0 { (*target).box_geom.width as f64 } else { 800.0 };
+                let h = if (*target).box_geom.height > 0 { (*target).box_geom.height as f64 } else { 600.0 };
+
+                // Center the window's virtual center in the viewport. Sets desk_pan
+                // directly (no animation) so the reply geometry is immediately valid.
+                let center_x = (*target).virtual_x + w / 2.0;
+                let center_y = (*target).virtual_y + h / 2.0;
+                self.desk_pan_x = center_x - (vp_w / 2.0) / self.desk_zoom;
+                self.desk_pan_y = center_y - (vp_h / 2.0) / self.desk_zoom;
+
+                if let Some(seat) = self.first_seat() {
+                    (*seat).focus(crate::seat::Focus::Window(target));
+                }
+                self.raise_window(target);
+                self.dirty_windowing();
+
+                // screen = output_origin + (virtual - desk_pan) * zoom
+                let screen_x = phys_x as f64 + ((*target).virtual_x - self.desk_pan_x) * self.desk_zoom;
+                let screen_y = phys_y as f64 + ((*target).virtual_y - self.desk_pan_y) * self.desk_zoom;
+                format!(
+                    "ok x={} y={} w={} h={}\n",
+                    screen_x.round() as i32,
+                    screen_y.round() as i32,
+                    (w * self.desk_zoom).round() as i32,
+                    (h * self.desk_zoom).round() as i32,
+                )
+            }
             "exit" => {
                 self.execute_action(&crate::config::Action::Exit, None);
                 "ok\n".to_string()