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

commit8f4cde87ce3c9285cadf6d6e61e655f19a3bc42f
parenta11295611f
authorLucas Galante <[email protected]>
date2026-08-20 10:30
feat: on_app_exit — choose the camera's reaction to a closed app

New window_manager.on_app_exit config (menu-typed, so the settings tree
renders a dropdown): "focus_previous" pans to the fallback focus (the
historic behavior), "overview" enters overview instead of chasing any
one window, "none" leaves the camera exactly where the user left it.
The fallback focus itself always transfers — keyboard input needs a
live target; only the camera differs. Implemented as a one-shot
focus-follow-pan suppression on the Seat around the
focus_next_visible_window funnel (close, unmap, minimize, layer
teardown), so explicit focus changes (clicks, directional focus, the
switcher) pan as always.

Shadow-verified: "none" keeps the camera still across a close;
"overview" flips wm-mode on close.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/server/config.rs         | 36 +++++++++++++++++++++++++++++++++++-
 src/server/seat.rs           | 11 +++++++++++
 src/server/window_manager.rs | 17 +++++++++++++++++
 3 files changed, 63 insertions(+), 1 deletion(-)

diff --git a/src/server/config.rs b/src/server/config.rs
index 734d4ce..fa740d8 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -315,6 +315,11 @@ pub struct WindowManagerConfig {
     /// 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>,
+    /// Camera reaction when the focused window goes away (app exit, close,
+    /// minimize): "focus_previous" (pan to the fallback focus — the historic
+    /// behavior), "overview", or "none". Menu-typed in config.kdl so the
+    /// settings tree renders a dropdown.
+    pub on_app_exit: Option<String>,
     /// Corner-shape exponent for scenefx's rounded-corner cuts (window
     /// surfaces, blur, shadows' clip): 2 = circular arc, > 2 = superellipse
     /// squircle. The same `window_manager.corner_shape` key the cce-ui
@@ -855,6 +860,28 @@ pub fn parse_hex_color_rgba(hex_str: &str) -> [f32; 4] {
     [0.05, 0.05, 0.05, 0.05] // fallback default (5% white)
 }
 
+/// Camera reaction when the focused window goes away — see
+/// `WindowManager::focus_next_visible_window`. The fallback focus itself
+/// always transfers (keyboard input needs a live target); this only decides
+/// what the CAMERA does about it.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum OnAppExit {
+    /// Pan to the fallback-focused window (the historic behavior).
+    FocusPrevious,
+    /// Enter overview instead of chasing any one window.
+    Overview,
+    /// The camera stays exactly where the user left it.
+    Nothing,
+}
+
+pub fn parse_on_app_exit(s: &str) -> OnAppExit {
+    match s.to_lowercase().as_str() {
+        "focus_previous" => OnAppExit::FocusPrevious,
+        "overview" => OnAppExit::Overview,
+        _ => OnAppExit::Nothing,
+    }
+}
+
 pub fn parse_tiling_mode(s: &str) -> TilingMode {
     match s.to_lowercase().as_str() {
         "fullscreen" => TilingMode::Fullscreen,
@@ -2023,10 +2050,11 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
         let window_switcher = get_child_arg_string_opt(node, "window_switcher");
         let window_switcher_prev = get_child_arg_string_opt(node, "window_switcher_prev");
         let center_on_spawn = get_child_arg_bool_opt(node, "center_on_spawn");
+        let on_app_exit = get_child_arg_string_opt(node, "on_app_exit");
         let corner_shape = get_child_arg_f64_opt(node, "corner_shape");
         let rounded_apps = get_child_args_string_vec_opt(node, "rounded_apps");
         let bevel_apps = get_child_args_string_vec_opt(node, "bevel_apps");
-        window_manager = Some(WindowManagerConfig { close_window, toggle_fullscreen, toggle_overview, window_switcher, window_switcher_prev, center_on_spawn, corner_shape, rounded_apps, bevel_apps });
+        window_manager = Some(WindowManagerConfig { close_window, toggle_fullscreen, toggle_overview, window_switcher, window_switcher_prev, center_on_spawn, on_app_exit, corner_shape, rounded_apps, bevel_apps });
     }
 
     Ok(Config {
@@ -2083,6 +2111,12 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
 
     state.output_scale = 1.0f32;
     state.display = config.display.clone();
+    state.on_app_exit = config
+        .window_manager
+        .as_ref()
+        .and_then(|wm| wm.on_app_exit.as_deref())
+        .map(parse_on_app_exit)
+        .unwrap_or(OnAppExit::FocusPrevious);
     state.center_on_spawn = config
         .window_manager
         .as_ref()
diff --git a/src/server/seat.rs b/src/server/seat.rs
index 31c7afd..3ea9ea8 100644
--- a/src/server/seat.rs
+++ b/src/server/seat.rs
@@ -91,6 +91,13 @@ pub struct Seat {
     pub modifiers_old: u32,
     pub op: Option<SeatOp>,
     pub op_release: bool,
+    /// One-shot focus-follow-pan suppression, set around refocuses caused
+    /// by a window GOING AWAY (close/unmap/minimize): the camera stays
+    /// where the user left it instead of chasing the fallback focus.
+    /// Explicit focus changes (clicks, directional focus, the switcher)
+    /// pan as always. Set-call-clear by the caller so a blocked focus
+    /// can't leak the flag into a later, legitimate pan.
+    pub suppress_focus_pan: bool,
     /// Overview-move displacement ledger: windows currently displaced out
     /// of the way of the active move op, with their pre-displacement
     /// virtual positions. While the button is held displacement is
@@ -140,6 +147,7 @@ impl Seat {
             modifiers_old: 0,
             op: None,
             op_release: false,
+            suppress_focus_pan: false,
             overview_displaced: Vec::new(),
             wm_sent_x: 0,
             wm_sent_y: 0,
@@ -815,6 +823,9 @@ impl Seat {
     /// not desk citizens, so other modes no-op, as do cce-cloud and windows
     /// already fully visible.
     pub unsafe fn focus_follow_pan(&mut self, window: *mut crate::window::Window) {
+        if self.suppress_focus_pan {
+            return;
+        }
         if window.is_null()
             || !matches!(
                 (*window).tiling_mode,
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index d9c35d4..01fa677 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -81,6 +81,9 @@ pub struct WindowManager {
     pub desk_pan_y: f64,
     pub desk_zoom: f64,
     pub mode: WindowManagerMode,
+    /// Camera reaction when the focused window goes away (config
+    /// `window_manager.on_app_exit`).
+    pub on_app_exit: crate::config::OnAppExit,
     /// From the last arrange plan: false while a live grid client covers
     /// the desktop, so `draw_grid` keeps only the backdrop (and labels).
     pub grid_cells_enabled: bool,
@@ -275,6 +278,7 @@ impl WindowManager {
         self.pending_screenshot = None;
         self.pending_ipc_reply = None;
         self.mode = WindowManagerMode::Normal;
+        self.on_app_exit = crate::config::OnAppExit::FocusPrevious;
         self.grid_cells_enabled = true;
         self.restore_queue = Vec::new();
         self.last_window_states = Vec::new();
@@ -2678,11 +2682,24 @@ impl WindowManager {
         let next = crate::policy::focus::next_visible_focus(&history, &windows)
             .and_then(|id| self.windows.get(id.0).copied())
             .unwrap_or(std::ptr::null_mut());
+        // The window the user was looking at went away. The fallback focus
+        // always transfers (keyboard input needs a live target); what the
+        // CAMERA does about it is the on_app_exit choice: pan to the
+        // fallback (the historic behavior), jump to overview, or stay
+        // exactly where the user left it.
+        let behavior = self.on_app_exit;
+        (*seat).suppress_focus_pan = behavior != crate::config::OnAppExit::FocusPrevious;
         if !next.is_null() {
             (*seat).focus(crate::seat::Focus::Window(next));
         } else {
             (*seat).focus(crate::seat::Focus::None);
         }
+        (*seat).suppress_focus_pan = false;
+        if behavior == crate::config::OnAppExit::Overview
+            && self.mode == WindowManagerMode::Normal
+        {
+            self.execute_action(&crate::config::Action::Overview, None);
+        }
     }
 
     /// True for windows that should appear in the window switcher: mapped,