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