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

commitaf2797d9c405c1b56dc289f766338020880642a4
parent03dbacd299
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-08 12:52
feat(ipc): ccectl focus-window waits until the window holds still

Focusing a window that hangs off the view pans it in, and its real
geometry moves with the animated camera, so a pointer command sent right
after `ccectl focus-window` landed where the window was mid-flight. In a
shadow that read as cce-fonts' search box refusing focus while its large
preview box, which a mid-flight click still hits, took clicks fine.

`focus-window --wait` holds the IPC reply (SettleWaiter) until no camera
pan, pinch or relayout is in flight and the window's on-screen box has
held still for three 16 ms polls, 3 s at most. ccectl sends --wait by
default; --no-wait restores the immediate reply. Apps forwarding a launch
through cce_core::ipc::focus_window send no flag and are unchanged.

Verified in a shadow: the reply came back after 510 ms with the window at
rest (100,100 -> 56,24), and an immediate click focused the search box
and filtered the list; ~80 ms for a window already in view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/cce_ctl.rs               |  41 +++++++++++-
 src/server/ipc_server.rs     |   4 +-
 src/server/server.rs         |   1 +
 src/server/window_manager.rs | 155 ++++++++++++++++++++++++++++++++++++++++++-
 4 files changed, 196 insertions(+), 5 deletions(-)

diff --git a/src/cce_ctl.rs b/src/cce_ctl.rs
index a51f0827..f8d6b6f8 100644
--- a/src/cce_ctl.rs
+++ b/src/cce_ctl.rs
@@ -51,7 +51,8 @@ fn usage(name: &str, to_stderr: bool) {
     print("  pan-down");
     print("  overlay-left");
     print("  overlay-right");
-    print("  focus-window <app_id>");
+    print("  focus-window [--no-wait] <app_id|id>  # focus and raise; replies once the window");
+    print("                              # stops moving (a pan into view), 3s at most");
     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)");
@@ -127,6 +128,25 @@ fn usage(name: &str, to_stderr: bool) {
     print("  shortcut unbind <session> [<id>] | clear | list");
 }
  
+/// The line sent for `args` (the command and its arguments). `focus-window`
+/// waits for the window to settle unless told `--no-wait`: focusing can pan
+/// the window into view, and a script's next pointer command would
+/// otherwise land where the window was mid-flight. Apps reach the
+/// compositor directly and keep the immediate reply.
+fn command_line(args: &[String]) -> String {
+    let mut args: Vec<&str> = args.iter().map(String::as_str).collect();
+    if args.first() == Some(&"focus-window") {
+        match args.get(1) {
+            Some(&"--no-wait") => {
+                args.remove(1);
+            }
+            Some(&"--wait") => {}
+            _ => args.insert(1, "--wait"),
+        }
+    }
+    args.join(" ") + "\n"
+}
+
 pub fn run_cce_ctl(args: Vec<String>) {
     if args.len() < 2 {
         usage(&args[0], true);
@@ -156,7 +176,7 @@ pub fn run_cce_ctl(args: Vec<String>) {
  
     let mut stream = stream;
     // Build command string from args
-    let cmd = args[1..].join(" ") + "\n";
+    let cmd = command_line(&args[1..]);
     if let Err(e) = stream.write_all(cmd.as_bytes()) {
         eprintln!("write: {}", e);
         process::exit(1);
@@ -178,3 +198,20 @@ pub fn run_cce_ctl(args: Vec<String>) {
         }
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::command_line;
+
+    fn line(args: &[&str]) -> String {
+        command_line(&args.iter().map(|s| s.to_string()).collect::<Vec<_>>())
+    }
+
+    #[test]
+    fn focus_window_waits_unless_told_not_to() {
+        assert_eq!(line(&["focus-window", "cce-fonts"]), "focus-window --wait cce-fonts\n");
+        assert_eq!(line(&["focus-window", "--wait", "12"]), "focus-window --wait 12\n");
+        assert_eq!(line(&["focus-window", "--no-wait", "cce-fonts"]), "focus-window cce-fonts\n");
+        assert_eq!(line(&["windows", "--json"]), "windows --json\n");
+    }
+}
diff --git a/src/server/ipc_server.rs b/src/server/ipc_server.rs
index 7c54b9d4..a15eee73 100644
--- a/src/server/ipc_server.rs
+++ b/src/server/ipc_server.rs
@@ -279,9 +279,11 @@ fn handle_client(mut stream: UnixStream, tx: IpcSender) {
                 // `lock` answers once the session IS locked: a frame on every
                 // output plus a locker starting, and the lock-before-sleep
                 // thread holds logind's sleep (5s at most) on that reply.
+                // `focus-window --wait` answers once the window holds still,
+                // `SETTLE_TIMEOUT_MS` (3s) at most.
                 let timeout = if cmd.starts_with("screenshot") {
                     std::time::Duration::from_secs(5)
-                } else if cmd == "lock" {
+                } else if cmd == "lock" || cmd.starts_with("focus-window --wait") {
                     std::time::Duration::from_secs(4)
                 } else {
                     std::time::Duration::from_millis(1000)
diff --git a/src/server/server.rs b/src/server/server.rs
index d0616ca0..ac5772ee 100644
--- a/src/server/server.rs
+++ b/src/server/server.rs
@@ -1012,6 +1012,7 @@ impl Default for Server {
             // is what makes zeroed bytes decode as a live `Some` there as well.
             std::ptr::write(&mut (*server.as_mut_ptr()).wm.pending_ipc_reply, None);
             std::ptr::write(&mut (*server.as_mut_ptr()).wm.pending_screenshot, None);
+            std::ptr::write(&mut (*server.as_mut_ptr()).wm.settle_waiters, Vec::new());
             std::ptr::write(&mut (*server.as_mut_ptr()).wm.startup, Vec::new());
             std::ptr::write(&mut (*server.as_mut_ptr()).wm.startup_pids, Vec::new());
             std::ptr::write(&mut (*server.as_mut_ptr()).wm.status_sender, None);
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 21d495ce..36b7af57 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -386,6 +386,50 @@ pub struct WindowManagerRenderingRequested {
 
 pub use crate::policy::state::{SavedState, SavedWindowState};
 
+/// The longest `focus-window --wait` holds its reply. A focus pan lands in
+/// well under a second; this only bounds a window that never holds still.
+pub const SETTLE_TIMEOUT_MS: u64 = 3000;
+/// How often a held `focus-window --wait` looks at its window.
+const SETTLE_POLL_MS: i32 = 16;
+/// Consecutive polls with nothing moving that count as settled: one poll
+/// can fall between two steps of an animation that is not the camera's.
+const SETTLE_STILL_POLLS: u32 = 3;
+
+/// A `focus-window --wait` caller, answered once its window has stopped
+/// moving on screen.
+///
+/// Focusing a window can move it: a window hanging off the view is panned
+/// into it, and the pan is animated. A script that clicks right after
+/// `focus-window` clicked where the window was going to be, mid-flight —
+/// cce-fonts' search box "did not take focus" in a shadow, while its large
+/// preview box, which a mid-flight click still lands in, did.
+pub struct SettleWaiter {
+    pub tx: std::sync::mpsc::Sender<String>,
+    pub window: crate::slotmap::Key,
+    pub started_ns: u64,
+    last_box: Option<(i32, i32, i32, i32)>,
+    still: u32,
+}
+
+impl SettleWaiter {
+    pub fn new(tx: std::sync::mpsc::Sender<String>, window: crate::slotmap::Key, started_ns: u64) -> Self {
+        Self { tx, window, started_ns, last_box: None, still: 0 }
+    }
+
+    /// One poll: the window's box on screen now, and whether anything that
+    /// moves windows is still in flight (the camera, a pending relayout).
+    /// True once the box has held still for [`SETTLE_STILL_POLLS`] polls.
+    fn observe(&mut self, screen_box: (i32, i32, i32, i32), moving: bool) -> bool {
+        if moving || self.last_box != Some(screen_box) {
+            self.still = 0;
+        } else {
+            self.still += 1;
+        }
+        self.last_box = Some(screen_box);
+        self.still >= SETTLE_STILL_POLLS
+    }
+}
+
 pub struct WindowManager {
     pub server: *mut Server,
     pub global: *mut ffi::wl_global,
@@ -459,6 +503,12 @@ pub struct WindowManager {
     /// dispatched, alongside `pending_ipc_reply`. `fade-out` resolves "the
     /// caller's own window" with it. 0 outside a dispatch.
     pub pending_ipc_peer_pid: i32,
+    /// `focus-window --wait` callers whose reply is held until the window
+    /// stops moving on screen (see [`SettleWaiter`]).
+    pub settle_waiters: Vec<SettleWaiter>,
+    /// Polls `settle_waiters`; created on the first wait, armed while any
+    /// waiter is left.
+    pub settle_timer: *mut ffi::wl_event_source,
     pub startup: Vec<crate::config::StartupConfig>,
     pub startup_pids: Vec<(crate::config::StartupConfig, nix::unistd::Pid)>,
     pub status_sender: Option<crate::status_server::StatusSender>,
@@ -879,6 +929,7 @@ impl WindowManager {
         self.pending_screenshot = None;
         self.pending_ipc_reply = None;
         self.pending_ipc_peer_pid = 0;
+        self.settle_timer = std::ptr::null_mut();
         self.mode = WindowManagerMode::Normal;
         self.on_app_exit = crate::config::OnAppExit::FocusPrevious;
         self.grid_cells_enabled = true;
@@ -2001,6 +2052,13 @@ impl WindowManager {
             ffi::wl_event_source_remove(self.edge_pan_timer);
             self.edge_pan_timer = std::ptr::null_mut();
         }
+        if !self.settle_timer.is_null() {
+            ffi::wl_event_source_remove(self.settle_timer);
+            self.settle_timer = std::ptr::null_mut();
+        }
+        // Dropping a waiter drops its reply channel: the caller is told the
+        // command timed out rather than left hanging.
+        self.settle_waiters.clear();
         if !self.restore_placeholder_timer.is_null() {
             ffi::wl_event_source_remove(self.restore_placeholder_timer);
             self.restore_placeholder_timer = std::ptr::null_mut();
@@ -2283,6 +2341,57 @@ impl WindowManager {
         }
     }
 
+    /// Poll the held `focus-window --wait` replies in [`SETTLE_POLL_MS`].
+    unsafe fn arm_settle_timer(&mut self) {
+        if self.settle_timer.is_null() {
+            let event_loop = ffi::wl_display_get_event_loop((*self.server).wl_server);
+            self.settle_timer = ffi::wl_event_loop_add_timer(
+                event_loop,
+                Some(handle_settle_tick),
+                self as *mut WindowManager as *mut _,
+            );
+        }
+        if self.settle_timer.is_null() {
+            // No timer: answer now rather than never.
+            for w in self.settle_waiters.drain(..) {
+                let _ = w.tx.send("ok\n".to_string());
+            }
+            return;
+        }
+        ffi::wl_event_source_timer_update(self.settle_timer, SETTLE_POLL_MS);
+    }
+
+    /// Answer each `focus-window --wait` whose window has stopped moving
+    /// on screen, or has waited [`SETTLE_TIMEOUT_MS`], or has closed.
+    unsafe fn poll_settle_waiters(&mut self) {
+        let moving = self.camera_anim_active
+            || self.pan_pending != [0.0, 0.0]
+            || self.pinch_pending.is_some()
+            || self.scheduled.dirty;
+        let now = crate::util::timestamp_ns();
+        let mut waiters = std::mem::take(&mut self.settle_waiters);
+        let mut kept = Vec::new();
+        for mut w in waiters.drain(..) {
+            let win = self.windows.get(w.window).copied().filter(|&p| !p.is_null() && !(*p).closed);
+            let Some(win) = win else {
+                let _ = w.tx.send("error: window closed before it settled\n".to_string());
+                continue;
+            };
+            let b = (*win).box_geom;
+            if w.observe((b.x, b.y, b.width, b.height), moving) {
+                let _ = w.tx.send("ok\n".to_string());
+            } else if now.saturating_sub(w.started_ns) >= SETTLE_TIMEOUT_MS * 1_000_000 {
+                let _ = w.tx.send(format!("ok (still moving after {} ms)\n", SETTLE_TIMEOUT_MS));
+            } else {
+                kept.push(w);
+            }
+        }
+        self.settle_waiters = kept;
+        if !self.settle_waiters.is_empty() {
+            self.arm_settle_timer();
+        }
+    }
+
     /// Ask every enabled output for a frame (the camera step runs in the
     /// frame handler). A no-op for an output that already has one pending.
     pub unsafe fn schedule_frame_all_outputs(&mut self) {
@@ -5900,9 +6009,15 @@ impl WindowManager {
                 "ok\n".to_string()
             }
             "focus-window" => {
-                if parts.len() < 2 { return "error: missing app_id/id\n".to_string(); }
+                // `--wait` holds the reply until the window has stopped
+                // moving on screen (`SettleWaiter`) — ccectl sends it. Apps
+                // forwarding a launch (`cce_core::ipc::focus_window`) do not:
+                // they wait a second at most and need no settling.
+                let wait = parts.get(1) == Some(&"--wait");
+                let query = parts[if wait { 2 } else { 1 }..].join(" ");
+                if query.is_empty() { return "error: missing app_id/id\n".to_string(); }
                 if let Some(seat) = self.first_seat() {
-                    let best_target = self.find_window_by_query(&parts[1..].join(" "));
+                    let best_target = self.find_window_by_query(&query);
                     if !best_target.is_null() {
                         if (*best_target).minimized {
                             (*best_target).minimized = false;
@@ -5910,6 +6025,14 @@ impl WindowManager {
                         (*seat).focus(crate::seat::Focus::Window(best_target));
                         self.raise_window(best_target);
                         self.dirty_windowing();
+                        if wait {
+                            if let Some(tx) = self.pending_ipc_reply.take() {
+                                let started = crate::util::timestamp_ns();
+                                self.settle_waiters.push(SettleWaiter::new(tx, (*best_target).ref_key, started));
+                                self.arm_settle_timer();
+                                return String::new();
+                            }
+                        }
                         "ok\n".to_string()
                     } else {
                         "error: window not found\n".to_string()
@@ -8116,6 +8239,15 @@ pub(crate) unsafe extern "C" fn handle_panning_animation_tick(data: *mut std::ff
     0
 }
 
+/// The `focus-window --wait` poll (`WindowManager::poll_settle_waiters`).
+unsafe extern "C" fn handle_settle_tick(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
+    let wm = data as *mut WindowManager;
+    if !wm.is_null() {
+        (*wm).poll_settle_waiters();
+    }
+    0
+}
+
 /// Steps every window's border hover fade, map/close dissolve, and any
 /// in-flight fullscreen-toggle animation — until all of them have settled. Windows at
 /// rest cost one comparison per zone and no repaint, so leaving this running
@@ -8179,6 +8311,25 @@ mod tests {
         }
     }
 
+    #[test]
+    fn a_window_settles_once_its_box_holds_still_with_nothing_in_flight() {
+        let (tx, _rx) = std::sync::mpsc::channel();
+        let key = crate::slotmap::Key { generation: 0, index: 0 };
+        let mut w = SettleWaiter::new(tx, key, 0);
+        // A pan in flight: the box moves, never settled.
+        for x in [100, 95, 62, 57, 56] {
+            assert!(!w.observe((x, 24, 1200, 720), true));
+        }
+        // The camera has landed but the box has not been seen still yet.
+        assert!(!w.observe((56, 24, 1200, 720), false));
+        assert!(!w.observe((56, 24, 1200, 720), false));
+        // A step of some other animation restarts the count.
+        assert!(!w.observe((57, 24, 1200, 720), false));
+        assert!(!w.observe((57, 24, 1200, 720), false));
+        assert!(!w.observe((57, 24, 1200, 720), false));
+        assert!(w.observe((57, 24, 1200, 720), false));
+    }
+
     #[test]
     fn an_empty_title_rule_reaches_an_untitled_window() {
         let any = rule("com.anthropic.Claude", Some(""));