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

commit82c7a3002e53723e2496cd548b6391955e3957ef
parent9cd7659938
authorLucas Galante <[email protected]>
date2026-07-28 20:28
fix: strict login focus settling + restore placeholders

The keepassxc-focused-after-login regression came back through two new
holes the log exposed: (1) a focus-steal ECHO — a prior login's steal
was still in effect at the next logout, so state.json saved keepassxc's
unlock dialog as the session's focused window and the next restore
faithfully focused it; (2) claude mapping 4s later claimed focus
UNCONDITIONALLY — yanking it even if the user had already started
typing their master password.

Focus rules now: until the session's focused window maps (or the user
types/clicks), NO window auto-focuses on map — restored siblings and
autostarts alike, in any load order, which also parks the focus-follow
pan at the saved camera; and the focused window itself only claims
focus if the user hasn't intervened yet. One clean logout clears the
echo. Plus the user-proposed placeholders: dim rounded frames at every
restored window's saved geometry (focused one in the focused border
color), camera-tracked like windows, replaced as programs map, swept
after 60s if one never does — the desk isn't a void while slow
programs load.

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

 src/server/window.rs         |  23 +++++-
 src/server/window_manager.rs | 168 ++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 187 insertions(+), 4 deletions(-)

diff --git a/src/server/window.rs b/src/server/window.rs
index d5541e7..2e8e9bc 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -1021,7 +1021,28 @@ impl Window {
             let mut should_focus = true;
             if self.restored && self.restored_focused {
                 (*self.server).wm.restored_focused_window_mapped = true;
-                log::info!("[FocusRestore] Restored focused window mapped: {:?}", self.get_title());
+                if (*self.server).wm.startup_input_seen {
+                    // The user already typed/clicked somewhere (e.g. into
+                    // the keepassxc unlock dialog) while this window was
+                    // still loading — mapping now must not yank focus out
+                    // from under them.
+                    log::info!("[FocusRestore] Restored focused window {:?} mapped after user input; leaving focus alone", self.get_title());
+                    should_focus = false;
+                } else {
+                    log::info!("[FocusRestore] Restored focused window mapped: {:?}", self.get_title());
+                }
+            } else if (*self.server).wm.has_restored_focused_window
+                && !(*self.server).wm.restored_focused_window_mapped
+                && !(*self.server).wm.startup_input_seen
+            {
+                // Strict settle phase: until the session's focused window
+                // maps (or the user intervenes), NOTHING else auto-focuses —
+                // neither restored siblings mapping first nor autostarts.
+                // This also keeps the focus-follow pan parked at the saved
+                // camera instead of wandering to whichever window loads
+                // fastest.
+                log::info!("[FocusRestore] Holding focus for the session's focused window; {:?} maps unfocused", self.get_title());
+                should_focus = false;
             } else if (*self.server).wm.has_restored_focused_window
                 && (*self.server).wm.restored_focused_window_mapped
             {
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 099203d..bd75991 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -124,6 +124,10 @@ pub struct WindowManager {
     pub edge_pan_timer: *mut ffi::wl_event_source,
     pub has_restored_focused_window: bool,
     pub restored_focused_window_mapped: bool,
+    /// Dim frames at restored windows' saved geometry while their programs
+    /// relaunch (see create_restore_placeholders).
+    pub restore_placeholders: Vec<RestorePlaceholder>,
+    pub restore_placeholder_timer: *mut ffi::wl_event_source,
     /// True after the first deliberate input (key or button press) of the
     /// session. Until then the session is still "settling" from restore:
     /// windows that map unbidden (autostarts like keepassxc) must not steal
@@ -213,6 +217,8 @@ impl WindowManager {
         self.display = std::collections::HashMap::new();
         self.has_restored_focused_window = false;
         self.restored_focused_window_mapped = false;
+        self.restore_placeholders = Vec::new();
+        self.restore_placeholder_timer = std::ptr::null_mut();
         self.startup_input_seen = false;
         self.mode_rules = Vec::new();
         self.keybinds = Vec::new();
@@ -319,6 +325,7 @@ impl WindowManager {
                     self.restore_queue.len(),
                     self.has_restored_focused_window
                 );
+                self.create_restore_placeholders();
             } else {
                 log::error!("Failed to parse state JSON from {}", path);
             }
@@ -327,6 +334,122 @@ impl WindowManager {
         }
     }
 
+    /// Dim frames at every restored window's saved geometry, shown from
+    /// login until the real window maps (or a timeout sweeps the leftovers):
+    /// the desk isn't a void while slow programs load, and the saved camera
+    /// has something to be pointed at. Purely visual — placeholders are
+    /// scene rects, not windows; focus logic never sees them.
+    pub unsafe fn create_restore_placeholders(&mut self) {
+        let parent = (*self.server).scene.layers.wm;
+        if parent.is_null() {
+            return;
+        }
+        for entry in &self.restore_queue {
+            if entry.width == 0 || entry.height == 0 {
+                continue;
+            }
+            let mut color = if entry.focused {
+                self.layout.border_color_focused
+            } else {
+                self.layout.border_color
+            };
+            // Dim: scale all channels (premultiplied convention).
+            for c in color.iter_mut() {
+                *c *= 0.25;
+            }
+            let rect = ffi::wlr_scene_rect_create(parent, entry.width as i32, entry.height as i32, color.as_ptr());
+            if rect.is_null() {
+                continue;
+            }
+            self.restore_placeholders.push(RestorePlaceholder {
+                rect,
+                app_id: entry.app_id.clone(),
+                title: entry.title.clone(),
+                vx: entry.virtual_x,
+                vy: entry.virtual_y,
+                w: entry.width,
+                h: entry.height,
+            });
+        }
+        if self.restore_placeholders.is_empty() {
+            return;
+        }
+        self.update_restore_placeholders();
+        // Sweep leftovers whose programs never came back.
+        let event_loop = ffi::wl_display_get_event_loop((*self.server).wl_server);
+        self.restore_placeholder_timer = ffi::wl_event_loop_add_timer(
+            event_loop,
+            Some(handle_restore_placeholder_timeout),
+            self as *mut WindowManager as *mut _,
+        );
+        if !self.restore_placeholder_timer.is_null() {
+            ffi::wl_event_source_timer_update(self.restore_placeholder_timer, 60_000);
+        }
+    }
+
+    /// Keep placeholders tracking the camera, same transform as windows.
+    pub unsafe fn update_restore_placeholders(&mut self) {
+        if self.restore_placeholders.is_empty() {
+            return;
+        }
+        let (mut out_x, mut out_y) = (0, 0);
+        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();
+                out_x = wlr_box.x;
+                out_y = wlr_box.y;
+                break;
+            }
+            curr_out = (*curr_out).next;
+        }
+        let zoom = self.desk_zoom;
+        let radius = (self.layout.backplate_corner_radius as f64 * zoom) as i32;
+        for p in &self.restore_placeholders {
+            let x = out_x + ((p.vx - self.desk_pan_x) * zoom) as i32;
+            let y = out_y + ((p.vy - self.desk_pan_y) * zoom) as i32;
+            ffi::river_scene_node_set_position_if_changed(p.rect as *mut ffi::wlr_scene_node, x, y);
+            ffi::river_scene_rect_set_size_if_changed(
+                p.rect,
+                (p.w as f64 * zoom) as i32,
+                (p.h as f64 * zoom) as i32,
+            );
+            ffi::river_scene_rect_set_corner_radius(p.rect, radius);
+        }
+    }
+
+    /// Drop the placeholder claimed by a matched restore entry.
+    unsafe fn remove_placeholder_for(&mut self, entry: &SavedWindowState) {
+        if let Some(pos) = self
+            .restore_placeholders
+            .iter()
+            .position(|p| p.app_id == entry.app_id && p.title == entry.title)
+        {
+            let p = self.restore_placeholders.remove(pos);
+            if !p.rect.is_null() {
+                ffi::wlr_scene_node_destroy(p.rect as *mut ffi::wlr_scene_node);
+            }
+        }
+        if self.restore_placeholders.is_empty() && !self.restore_placeholder_timer.is_null() {
+            ffi::wl_event_source_remove(self.restore_placeholder_timer);
+            self.restore_placeholder_timer = std::ptr::null_mut();
+        }
+    }
+
+    pub unsafe fn clear_restore_placeholders(&mut self) {
+        for p in self.restore_placeholders.drain(..) {
+            if !p.rect.is_null() {
+                ffi::wlr_scene_node_destroy(p.rect as *mut ffi::wlr_scene_node);
+            }
+        }
+        if !self.restore_placeholder_timer.is_null() {
+            ffi::wl_event_source_remove(self.restore_placeholder_timer);
+            self.restore_placeholder_timer = std::ptr::null_mut();
+        }
+    }
+
     pub unsafe fn save_state(&mut self) {
         if self.shutting_down {
             return;
@@ -544,7 +667,9 @@ impl WindowManager {
         }
         // First pass: Exact match (app_id AND title)
         if let Some(pos) = self.restore_queue.iter().position(|w| w.app_id == app_id && w.title == title) {
-            return Some(self.restore_queue.remove(pos));
+            let entry = self.restore_queue.remove(pos);
+            self.remove_placeholder_for(&entry);
+            return Some(entry);
         }
         // Second pass: Fuzzy title match (e.g. prefix match, asterisk stripping)
         if let Some(pos) = self.restore_queue.iter().position(|w| {
@@ -555,11 +680,15 @@ impl WindowManager {
             let t2 = w.title.trim_end_matches('*');
             t1 == t2 || t1.starts_with(t2) || t2.starts_with(t1)
         }) {
-            return Some(self.restore_queue.remove(pos));
+            let entry = self.restore_queue.remove(pos);
+            self.remove_placeholder_for(&entry);
+            return Some(entry);
         }
         // Third pass: app_id only match
         if let Some(pos) = self.restore_queue.iter().position(|w| w.app_id == app_id) {
-            return Some(self.restore_queue.remove(pos));
+            let entry = self.restore_queue.remove(pos);
+            self.remove_placeholder_for(&entry);
+            return Some(entry);
         }
         None
     }
@@ -658,6 +787,12 @@ impl WindowManager {
             ffi::wl_event_source_remove(self.edge_pan_timer);
             self.edge_pan_timer = std::ptr::null_mut();
         }
+        if !self.restore_placeholder_timer.is_null() {
+            ffi::wl_event_source_remove(self.restore_placeholder_timer);
+            self.restore_placeholder_timer = std::ptr::null_mut();
+        }
+        // Placeholder rects go down with the scene; only the bookkeeping.
+        self.restore_placeholders.clear();
         if !self.viewport_settle_timer.is_null() {
             ffi::wl_event_source_remove(self.viewport_settle_timer);
             self.viewport_settle_timer = std::ptr::null_mut();
@@ -1377,6 +1512,7 @@ impl WindowManager {
 
 
     pub unsafe fn arrange_views(&mut self) {
+        self.update_restore_placeholders();
         log::debug!("Monolithic arrange_views triggered. Windows: {}", self.windows.count());
         if log::log_enabled!(log::Level::Debug) {
             for (idx, &win_ptr) in self.windows.iter().enumerate() {
@@ -3875,6 +4011,32 @@ impl crate::policy::api::Compositor for WindowManager {
     }
 }
 
+/// One dim frame standing in for a restored window until its program maps.
+pub struct RestorePlaceholder {
+    pub rect: *mut ffi::wlr_scene_rect,
+    pub app_id: String,
+    pub title: String,
+    pub vx: f64,
+    pub vy: f64,
+    pub w: u32,
+    pub h: u32,
+}
+
+/// Sweep placeholders whose programs never came back.
+pub(crate) unsafe extern "C" fn handle_restore_placeholder_timeout(
+    data: *mut std::ffi::c_void,
+) -> std::os::raw::c_int {
+    let wm = data as *mut WindowManager;
+    if !wm.is_null() {
+        log::info!(
+            "[Restore] Sweeping {} placeholder(s) whose windows never mapped",
+            (*wm).restore_placeholders.len()
+        );
+        (*wm).clear_restore_placeholders();
+    }
+    0
+}
+
 /// 16ms edge auto-pan tick: scroll the desktop by the current velocity, then
 /// re-run the seat op at its last cursor position so the dragged window keeps
 /// tracking the (pinned) cursor — the op's pan-delta term turns the scroll