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

commitba2a938007d0068fa960c3ea9a38646e17f1c638
parent8fc82b89f6
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 20:39
fix(fullscreen): a relaunched window enters fullscreen where it left it

Every fullscreen enter anchored the window's desk spot to the current
camera, and save_state recorded only the pre-fullscreen position, so a
game reopened wherever the user was looking. Trackmania was worse off:
as an xwayland_hidpi_except window it skipped try_restore entirely, and
its last_window_states slot was shared with (and overwritten by) Ubisoft
Connect, since every Proton program is steam_proton.

- save_state records the spot as `fullscreen_at` (the last one, for a
  window that left fullscreen before closing).
- try_restore hands it to the window, hidpi-exempt games included (the
  spot only, never their size); the first fullscreen enter lands on it
  and eases the camera there.
- place_fullscreen_windows schedules a save when a pinned spot moves,
  since a camera pan relays out without the transaction that saves.
- last_window_states is keyed on app_id and program.

Verified in a shadow with foot -F (Wayland) and an X11 mpv named in
xwayland_hidpi_except: closed, camera moved away, relaunched, and the
fullscreen enter brought the camera back to the saved spot.

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

 CLAUDE.md                    | 18 +++++++++++
 Cargo.lock                   |  2 +-
 Cargo.toml                   |  2 +-
 src/server/window.rs         | 72 +++++++++++++++++++++++++++++++++++++++++---
 src/server/window_manager.rs | 48 ++++++++++++++++++++++++++++-
 5 files changed, 135 insertions(+), 7 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index ddf3e729..02804c77 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1021,6 +1021,24 @@ An overview exit onto it lands exactly on its spot — `exit_onto_window`
 centres its output-sized rect — and only then pins; `set_mode` dirties
 windowing while a fullscreen window exists so the restack runs.
 
+**The spot survives a relaunch** (since 2026-10-06). `save_state` records
+it as `fullscreen_at` beside the pre-fullscreen `virtual_x/y` (still the
+spot it had when it left fullscreen, `last_fullscreen_at`, for a window
+closed windowed), and `try_restore` hands it to the window as
+`restore_fullscreen_at` — even for an `xwayland_hidpi_except` game, which
+takes nothing else from its entry. The first fullscreen enter lands there
+instead of on the view and eases the camera along
+(`pan_to_restored_fullscreen_spot`). Before, every enter took the view,
+so Trackmania opened wherever the user was looking. A camera pan relays
+out without a transaction, so `place_fullscreen_windows` schedules the
+save itself when a pinned spot moves. `last_window_states` is keyed on
+app_id AND program (`last_state_slot`) for the same game: every Proton
+program is `steam_proton`, and Ubisoft Connect, still up after the game
+closed, used to overwrite its entry. In a shadow, an X11 client named in
+`xwayland_hidpi_except` stands in (`mpv --vo=x11`, toggled with `xdotool
+windowstate --remove/--add FULLSCREEN`, since a fullscreen set before map
+sends no request).
+
 ### xdg-activation
 
 `handle_request_activate` (`server.rs`) runs for every activation wlroots
diff --git a/Cargo.lock b/Cargo.lock
index e51070ed..461daf74 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -497,7 +497,7 @@ dependencies = [
 [[package]]
 name = "cce-window-manager"
 version = "0.1.0"
-source = "git+https://github.com/lsgalante/cce-window-manager.git?rev=1279fc59630d346efc14f803e3211c7a66d8ae96#1279fc59630d346efc14f803e3211c7a66d8ae96"
+source = "git+https://github.com/lsgalante/cce-window-manager.git?rev=a3e2b0aa0880584aea7be4637243f95c85b12107#a3e2b0aa0880584aea7be4637243f95c85b12107"
 dependencies = [
  "log",
  "serde",
diff --git a/Cargo.toml b/Cargo.toml
index 31e9caff..e797e534 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,7 +8,7 @@ doctest = false
 
 [dependencies]
 cce-ui = { git = "https://github.com/lsgalante/cce-ui.git", rev = "3750aed83189154ad9d8f59aa7d2502bc55de246" }
-cce-window-manager = { git = "https://github.com/lsgalante/cce-window-manager.git", rev = "1279fc59630d346efc14f803e3211c7a66d8ae96" }
+cce-window-manager = { git = "https://github.com/lsgalante/cce-window-manager.git", rev = "a3e2b0aa0880584aea7be4637243f95c85b12107" }
 xkbcommon = "0.7"
 serde = { version = "1", features = ["derive"] }
 nix = { version = "0.29", features = ["signal", "process", "fs", "poll", "resource"] }
diff --git a/src/server/window.rs b/src/server/window.rs
index 27dc7d69..9b046af1 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -572,6 +572,14 @@ pub struct Window {
     /// output: stepped aside, or sliding back in under the camera. Set by
     /// `WindowManager::place_fullscreen_windows`, read by the render pass.
     pub fs_on_desk: bool,
+    /// The desk spot this window covered when its previous incarnation was
+    /// last saved fullscreen (`SavedWindowState::fullscreen_at`), set by
+    /// `try_restore` and spent by the first fullscreen enter, which lands
+    /// there and brings the camera along instead of anchoring to the view.
+    pub restore_fullscreen_at: Option<(f64, f64)>,
+    /// The desk spot this window covered the last time it LEFT fullscreen,
+    /// so `save_state` can still name one for a window closed windowed.
+    pub last_fullscreen_at: Option<(f64, f64)>,
     pub saved_width: i32,
     pub saved_height: i32,
     pub saved_virtual_x: f64,
@@ -714,6 +722,36 @@ impl Window {
         ))
     }
 
+    /// Eases the camera onto a fullscreen enter's restored desk spot, so the
+    /// window rides the desk there (`place_fullscreen_windows` reads the
+    /// target as `returning`) and pins on landing — the same slide a
+    /// stepped-aside window takes back. Only for a window that will be on
+    /// top: a stepped-aside one stays at its spot until it is focused, and
+    /// that focus pans (`Seat::focus_follow_pan`). Not in overview or under
+    /// a camera flight, where the window is a slab on the desk anyway and
+    /// the camera is not the enter's to move.
+    unsafe fn pan_to_restored_fullscreen_spot(&self) {
+        let wm = &mut (*self.server).wm;
+        if wm.mode == crate::window_manager::WindowManagerMode::Overview
+            || wm.camera_ramp_anim.is_some()
+            || self.fullscreen_yields()
+        {
+            return;
+        }
+        let Some((px, py)) = self.fullscreen_anchor_pan() else { return };
+        if (wm.desk_pan_x - px).abs() >= 0.5 || (wm.desk_pan_y - py).abs() >= 0.5 {
+            log::info!(
+                "[Fullscreen] {:?} enters at its saved desk spot ({:.0}, {:.0}); panning there",
+                self.get_title_string().as_deref().unwrap_or(""),
+                self.virtual_x,
+                self.virtual_y
+            );
+            wm.target_desk_pan_x = Some(px);
+            wm.target_desk_pan_y = Some(py);
+            wm.start_panning_animation();
+        }
+    }
+
     pub unsafe fn role(&self) -> crate::policy::api::WindowRole {
         if self.grid_declared {
             return crate::policy::api::WindowRole::Grid;
@@ -936,6 +974,8 @@ impl Window {
             commit: std::mem::zeroed(),
             was_fullscreen: false,
             fs_on_desk: false,
+            restore_fullscreen_at: None,
+            last_fullscreen_at: None,
             saved_width: 0,
             saved_height: 0,
             saved_virtual_x: 0.0,
@@ -1302,11 +1342,22 @@ impl Window {
         // the screen; restoring a saved size onto it is what shrank
         // Trackmania to the launcher's 1214x689 — the game then pinned that
         // size in its hints and no fullscreen could take. Mark it restored
-        // so nothing else tries.
+        // so nothing else tries. Where on the desk it was fullscreen is the
+        // compositor's to remember, though, not the game's: that alone is
+        // taken from its entry (`restore_fullscreen_at`).
         if crate::xwayland_window::window_is_hidpi_exempt(self as *const Window) {
+            let app_id = self.get_app_id_string().unwrap_or_default();
+            let title = self.get_title_string().unwrap_or_default();
+            let program = crate::window_manager::proc_args(self.unreliable_pid()).into_iter().next();
+            let wm = &mut (*self.server).wm;
+            let saved = wm
+                .match_and_remove_restore_state(&app_id, &title, program.as_deref())
+                .or_else(|| wm.match_last_window_state(&app_id, &title, program.as_deref()));
+            self.restore_fullscreen_at = saved.and_then(|s| s.fullscreen_at);
             log::info!(
-                "Not restoring saved state for {:?}: named in xwayland_hidpi_except, it places itself",
-                self.get_title_string().unwrap_or_default()
+                "Not restoring saved state for {:?}: named in xwayland_hidpi_except, it places itself (saved fullscreen spot: {:?})",
+                title,
+                self.restore_fullscreen_at
             );
             self.restored = true;
             return;
@@ -1399,6 +1450,7 @@ impl Window {
             }
             self.virtual_x = saved.virtual_x;
             self.virtual_y = saved.virtual_y;
+            self.restore_fullscreen_at = saved.fullscreen_at;
             self.scale = saved.scale;
             self.box_geom.width = saved.width as i32;
             self.box_geom.height = saved.height as i32;
@@ -3055,9 +3107,20 @@ impl Window {
                 // — where the camera is now — so stepping aside leaves it
                 // there (`WindowManager::place_fullscreen_windows`, which
                 // keeps it in step while the window is on top).
-                let (vx, vy) = self.screen_to_virtual((*output).sent.x, (*output).sent.y);
+                //
+                // Unless a previous incarnation was saved fullscreen
+                // somewhere (`try_restore`): then the spot is that one, and
+                // the camera goes to it. Trackmania reopened wherever the
+                // user happened to be looking, because the enter always
+                // took the view and only the pre-fullscreen spot was saved.
+                let restored_spot = self.restore_fullscreen_at.take();
+                let (vx, vy) = restored_spot
+                    .unwrap_or_else(|| self.screen_to_virtual((*output).sent.x, (*output).sent.y));
                 self.virtual_x = vx;
                 self.virtual_y = vy;
+                if restored_spot.is_some() {
+                    self.pan_to_restored_fullscreen_spot();
+                }
                 log::info!("[Fullscreen] Saved window {:?} geometry: {}x{} at ({}, {})", self.get_title_string().as_deref().unwrap_or(""), self.saved_width, self.saved_height, self.saved_virtual_x, self.saved_virtual_y);
             }
         } else if !new_fullscreen && self.was_fullscreen {
@@ -3065,6 +3128,7 @@ impl Window {
                 // Captures the on-screen fullscreen rect before the restore
                 // below rewrites box_geom.
                 self.start_fs_anim();
+                self.last_fullscreen_at = Some((self.virtual_x, self.virtual_y));
                 self.box_geom.width = self.saved_width;
                 self.box_geom.height = self.saved_height;
                 self.virtual_x = self.saved_virtual_x;
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 819a5121..04142ee8 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -157,6 +157,14 @@ fn borrowable(saved: &SavedWindowState, program: Option<&str>) -> bool {
     !saved.title.is_empty() && same_program(saved, program)
 }
 
+/// Whether `saved` is the `last_window_states` slot a window of `app_id`
+/// running `program` writes to: one per app_id and program. An entry or a
+/// window with no program to tell by shares the app_id's slot, as every
+/// entry did before programs counted (`same_program`'s leniency).
+fn last_state_slot(saved: &SavedWindowState, app_id: &str, program: Option<&str>) -> bool {
+    saved.app_id == app_id && same_program(saved, program)
+}
+
 /// Whether the session restore can relaunch a window from this saved
 /// command. A Wine/Proton window records its WINDOWS-side exe path
 /// (`C:\...` or `C:/...`) — /bin/sh can never run it — and an empty
@@ -1408,6 +1416,11 @@ impl WindowManager {
                 cmdline,
                 focused: is_focused,
                 argv: (!args.is_empty()).then(|| args.clone()),
+                fullscreen_at: if (*w).was_fullscreen {
+                    Some(((*w).virtual_x, (*w).virtual_y))
+                } else {
+                    (*w).last_fullscreen_at.or((*w).restore_fullscreen_at)
+                },
             };
 
             saved_wins.push(win_state.clone());
@@ -1419,7 +1432,12 @@ impl WindowManager {
             if title.is_empty() {
                 continue;
             }
-            if let Some(pos) = last_states.iter().position(|s| s.app_id == app_id) {
+            // One slot per app_id AND program: every Proton program is
+            // `steam_proton`, so keyed on the app_id alone Trackmania and
+            // the Ubisoft Connect it launches from took turns evicting each
+            // other, and the game's entry was gone by the time it was next
+            // launched — Connect is still up after the game closes.
+            if let Some(pos) = last_states.iter().position(|s| last_state_slot(s, &app_id, args.first().map(String::as_str))) {
                 last_states[pos] = win_state;
             } else {
                 last_states.push(win_state);
@@ -4311,6 +4329,7 @@ impl WindowManager {
     /// this would overwrite.
     unsafe fn place_fullscreen_windows(&mut self) {
         let zoom = self.desk_zoom;
+        let mut spot_moved = false;
         for &w in self.windows.iter() {
             if w.is_null() || (*w).closed {
                 continue;
@@ -4345,12 +4364,20 @@ impl WindowManager {
                 let output = (*w).fullscreen_output();
                 if !output.is_null() {
                     let (vx, vy) = (*w).screen_to_virtual((*output).sent.x, (*output).sent.y);
+                    // The spot is saved (`fullscreen_at`), and a camera
+                    // pan relays out without a transaction, which is
+                    // what normally schedules the save — so a game closed
+                    // after a pan would reopen where the pan began.
+                    spot_moved |= (vx - (*w).virtual_x).abs() >= 0.5 || (vy - (*w).virtual_y).abs() >= 0.5;
                     (*w).virtual_x = vx;
                     (*w).virtual_y = vy;
                 }
             }
             (*w).fs_on_desk = on_desk;
         }
+        if spot_moved {
+            self.schedule_save_state();
+        }
     }
 
     pub unsafe fn update_viewport_local(&mut self) {
@@ -8215,6 +8242,7 @@ mod tests {
             cmdline: "test-app".to_string(),
             focused: false,
             argv: None,
+            fullscreen_at: None,
         });
 
         unsafe {
@@ -8257,6 +8285,7 @@ mod tests {
             cmdline: cmdline.to_string(),
             focused: false,
             argv: None,
+            fullscreen_at: None,
         }
     }
 
@@ -8342,6 +8371,23 @@ mod tests {
     const UPC: &str = r"C:\Program Files (x86)\Ubisoft\Ubisoft Game Launcher\upc.exe";
     const EXPLORER: &str = r"C:\windows\system32\explorer.exe";
 
+    /// Trackmania and the Ubisoft Connect it launches from are both
+    /// `steam_proton`; each keeps its own `last_window_states` slot, while
+    /// an entry or window with no program still shares the app_id's one.
+    #[test]
+    fn last_state_slots_are_per_program() {
+        const TM: &str = "C:/Program Files (x86)/Ubisoft/Ubisoft Game Launcher/games/Trackmania/Trackmania.exe";
+        let upc = proton_entry("Ubisoft Connect", &format!("{UPC} -upc_desktop_mode"));
+        let tm = proton_entry("Trackmania", &format!("{TM}      "));
+        assert!(last_state_slot(&upc, "steam_proton", Some(UPC)));
+        assert!(!last_state_slot(&upc, "steam_proton", Some(TM)));
+        assert!(last_state_slot(&tm, "steam_proton", Some(TM)));
+        assert!(!last_state_slot(&tm, "steam_proton", Some(UPC)));
+        assert!(!last_state_slot(&tm, "other", Some(TM)));
+        assert!(last_state_slot(&tm, "steam_proton", None));
+        assert!(last_state_slot(&proton_entry("x", "steam_proton"), "steam_proton", Some(TM)));
+    }
+
     #[test]
     fn same_program_compares_argv0_by_prefix() {
         let saved = proton_entry("Ubisoft Connect", &format!("{UPC} -upc_desktop_mode --disable-gpu"));