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