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

commit8aa212f138199fa89555ac39e3b9621fc59197a3
parent7bc8de8566
authorLucas Galante <[email protected]>
date2026-07-18 16:55
feat: add window_manager.center_on_spawn to control spawn panning

A newly spawned window pulled the viewport over to it unconditionally.
`window_manager { center_on_spawn (bool)false }` leaves the desk where it
is and lets the window open wherever the layout placed it.

Distinguishes a startup restore from a mid-session reopen: only windows
coming out of state.json's restore queue set `session_restored`, so an app
reopened later still counts as a fresh spawn even though it borrowed its
old geometry from `last_window_states`. Focus-follow panning between
windows that were already up is unaffected either way.

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

 src/server/config.rs         | 39 ++++++++++++++++++++++++++++++++++++++-
 src/server/seat.rs           |  9 ++++++++-
 src/server/window.rs         |  8 ++++++++
 src/server/window_manager.rs |  6 ++++++
 4 files changed, 60 insertions(+), 2 deletions(-)

diff --git a/src/server/config.rs b/src/server/config.rs
index 9af8dad..9a4a9bc 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -231,6 +231,9 @@ pub struct WindowManagerConfig {
     pub toggle_fullscreen: Option<String>,
     pub toggle_overview: Option<String>,
     pub window_switcher: Option<String>,
+    /// Whether a newly spawned window pulls the viewport over to it. `None` = the default,
+    /// which is to centre (what the compositor has always done).
+    pub center_on_spawn: Option<bool>,
 }
 
 #[derive(Debug, Deserialize, Clone, Default, PartialEq, Eq)]
@@ -1631,7 +1634,8 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
         let toggle_fullscreen = get_child_arg_string_opt(node, "toggle_fullscreen");
         let toggle_overview = get_child_arg_string_opt(node, "toggle_overview");
         let window_switcher = get_child_arg_string_opt(node, "window_switcher");
-        window_manager = Some(WindowManagerConfig { close_window, toggle_fullscreen, toggle_overview, window_switcher });
+        let center_on_spawn = get_child_arg_bool_opt(node, "center_on_spawn");
+        window_manager = Some(WindowManagerConfig { close_window, toggle_fullscreen, toggle_overview, window_switcher, center_on_spawn });
     }
 
     Ok(Config {
@@ -1688,6 +1692,11 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
 
     state.output_scale = 1.0f32;
     state.display = config.display.clone();
+    state.center_on_spawn = config
+        .window_manager
+        .as_ref()
+        .and_then(|wm| wm.center_on_spawn)
+        .unwrap_or(true);
 
     state.layout.gap = config.layout.gap as i32;
     state.layout.gap_top = config.layout.gap_top as i32;
@@ -2024,6 +2033,34 @@ mod tests {
         assert_eq!(wm.close_window, Some("super+q".to_string()));
         assert_eq!(wm.toggle_fullscreen, Some("super+f".to_string()));
         assert_eq!(wm.toggle_overview, Some("swipe_up".to_string()));
+        // Absent means "unset", which the apply step reads as the centring default.
+        assert_eq!(wm.center_on_spawn, None);
+    }
+
+    #[test]
+    fn test_kdl_window_manager_center_on_spawn() {
+        let off = parse_kdl_config(
+            r#"
+            window_manager {
+                center_on_spawn (bool)false
+            }
+        "#,
+        )
+        .unwrap();
+        assert_eq!(off.window_manager.unwrap().center_on_spawn, Some(false));
+
+        let on = parse_kdl_config(
+            r#"
+            window_manager {
+                center_on_spawn (bool)true
+            }
+        "#,
+        )
+        .unwrap();
+        assert_eq!(on.window_manager.unwrap().center_on_spawn, Some(true));
+
+        // No window_manager block at all: nothing to read, and the apply step defaults on.
+        assert!(parse_kdl_config("layout {\n gap 4\n}").unwrap().window_manager.is_none());
     }
 
     #[test]
diff --git a/src/server/seat.rs b/src/server/seat.rs
index fe7c11e..a57a9ae 100644
--- a/src/server/seat.rs
+++ b/src/server/seat.rs
@@ -422,7 +422,14 @@ impl Seat {
                 if !window.is_null() && (*window).tiling_mode == crate::tiling::TilingMode::Floating {
                     let app_id = (*window).get_app_id_string();
                     let is_cce_cloud = app_id.as_ref().map(|id| id == "cce-cloud").unwrap_or(false);
-                    let should_pan = (!is_new || !(*window).restored) && !is_cce_cloud;
+                    // A window newly on screen pulls the viewport over to it only when
+                    // `window_manager.center_on_spawn` allows it; one coming back from the
+                    // saved session at startup never did. Reopening an app mid-session is a
+                    // 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 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;
                         let mut curr_out = (*outputs_list).next;
diff --git a/src/server/window.rs b/src/server/window.rs
index 753b18a..bcc4a89 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -265,6 +265,11 @@ pub struct Window {
     pub mode_locked: bool,
     pub is_new: bool,
     pub restored: 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
+    /// fresh spawn for `center_on_spawn`.
+    pub session_restored: bool,
     pub restored_focused: bool,
     pub closed: bool,
     pub has_parent: bool,
@@ -449,6 +454,7 @@ impl Window {
             mode_locked: false,
             is_new: true,
             restored: false,
+            session_restored: false,
             restored_focused: false,
             closed: false,
             has_parent: false,
@@ -726,6 +732,7 @@ impl Window {
         }
         let title_str = self.get_title_string().unwrap_or_default();
         let mut saved_opt = (*self.server).wm.match_and_remove_restore_state(&app_id_str, &title_str);
+        let from_session = saved_opt.is_some();
         if saved_opt.is_none() {
             saved_opt = (*self.server).wm.match_last_window_state(&app_id_str, &title_str);
         }
@@ -770,6 +777,7 @@ impl Window {
             }
 
             self.restored = true;
+            self.session_restored = from_session;
             self.restored_focused = saved.focused;
         }
     }
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index ac0c407..8c8ea70 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -124,6 +124,10 @@ pub struct WindowManager {
     pub viewport_settle_timer: *mut ffi::wl_event_source,
     pub clean_exit_in_progress: bool,
     pub clean_exit_timer: *mut ffi::wl_event_source,
+    /// `window_manager.center_on_spawn`: whether a newly spawned window pulls the viewport
+    /// over to it when it takes focus. Off, the desk stays put and the window opens wherever
+    /// the layout placed it. Focus-follow panning between EXISTING windows is unaffected.
+    pub center_on_spawn: bool,
 }
 
 impl WindowManager {
@@ -228,6 +232,8 @@ impl WindowManager {
             return Err("Failed to create clean exit timer event source");
         }
         self.clean_exit_in_progress = false;
+        // Default until the config is parsed (which happens after this init).
+        self.center_on_spawn = true;
 
         self.global = ffi::wl_global_create(
             (*server).wl_server,