git.lucas.co / cce-window-manager
window management library
git clone https://git.lucas.co/cce-window-manager.git

commit825f2e6d51cc78828d10932cebd78572bd397606
parent6b83b0ff56
authorLucas Galante <[email protected]>
date2026-09-06 13:06
fix(fullscreen): the toggle's exit restores the mode and lock it replaced

Leaving fullscreen unlocked the window to its viewport-resolved mode, so a
window tiled by hand (locked Tiled, no rule) came back Floating at the same
geometry. That is not what a toggle implies, and it has a visible cost for
Chromium-family clients: Tiled tells the client it is maximized and only
then does it drop its client-side shadow band, so the un-tiled window grew
a margin and its corners read square.

ActionWindow carries `pre_fullscreen` — the (mode, locked) pair the
mechanism records when a SetWindowMode makes the window Fullscreen — and
the exit restores it. Without a record, or with one that itself says
Fullscreen, the resolved-mode fallback stands.

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

 src/actions.rs | 57 +++++++++++++++++++++++++++++++++++++++++++++++++--------
 src/api.rs     |  6 ++++++
 2 files changed, 55 insertions(+), 8 deletions(-)

diff --git a/src/actions.rs b/src/actions.rs
index ab0ddc0..2f24ae4 100644
--- a/src/actions.rs
+++ b/src/actions.rs
@@ -392,19 +392,30 @@ fn focus_directional(ctx: &ActionCtx, action: Action) -> Vec<Command> {
     vec![Command::Focus(id), Command::Raise(id), Command::Relayout]
 }
 
-/// Toggle fullscreen on the focused window. Leaving fullscreen unlocks the
-/// window back to its viewport-resolved mode (Floating if that resolution is
-/// itself Fullscreen).
+/// Toggle fullscreen on the focused window. Leaving fullscreen puts the
+/// window back the way the toggle found it — mode AND lock, so a window
+/// tiled by hand stays tiled (the mechanism records both when it applies
+/// the Fullscreen `SetWindowMode`). Without that record it unlocks to the
+/// viewport-resolved mode (Floating if that resolution is itself
+/// Fullscreen). Restoring matters beyond the mode itself: Tiled tells the
+/// client it is maximized, and Chromium-family clients drop their
+/// client-side shadow band only then.
 fn fullscreen(ctx: &ActionCtx) -> Vec<Command> {
     let Some(id) = ctx.focused else { return Vec::new() };
     let Some(win) = window(ctx, id) else { return Vec::new() };
     let cmd = if win.mode == TilingMode::Fullscreen {
-        let target = if win.resolved_mode == TilingMode::Fullscreen {
-            TilingMode::Floating
-        } else {
-            win.resolved_mode
+        let (mode, locked) = match win.pre_fullscreen {
+            Some((mode, locked)) if mode != TilingMode::Fullscreen => (mode, locked),
+            _ => {
+                let target = if win.resolved_mode == TilingMode::Fullscreen {
+                    TilingMode::Floating
+                } else {
+                    win.resolved_mode
+                };
+                (target, false)
+            }
         };
-        Command::SetWindowMode { id, mode: target, locked: false }
+        Command::SetWindowMode { id, mode, locked }
     } else {
         Command::SetWindowMode { id, mode: TilingMode::Fullscreen, locked: true }
     };
@@ -487,6 +498,7 @@ mod tests {
             scale: 1.0,
             mode: TilingMode::Floating,
             resolved_mode: TilingMode::Floating,
+            pre_fullscreen: None,
             visible: true,
             focus_cyclable: true,
             overview_eligible: true,
@@ -649,6 +661,35 @@ mod tests {
         );
     }
 
+    #[test]
+    fn fullscreen_exit_restores_the_recorded_mode_and_lock() {
+        let mut c = ctx();
+        c.focused = Some(wid(1));
+        c.windows.push(win(1, 0.0, 0.0, 100.0, 100.0));
+        c.windows[0].mode = TilingMode::Fullscreen;
+        // A hand-tiled (locked) window comes back Tiled and locked, whatever
+        // the viewport would resolve it to.
+        c.windows[0].resolved_mode = TilingMode::Floating;
+        c.windows[0].pre_fullscreen = Some((TilingMode::Tiled, true));
+        assert_eq!(
+            dispatch(&c, Action::Fullscreen)[0],
+            Command::SetWindowMode { id: wid(1), mode: TilingMode::Tiled, locked: true }
+        );
+        // An unlocked Floating window comes back unlocked.
+        c.windows[0].pre_fullscreen = Some((TilingMode::Floating, false));
+        assert_eq!(
+            dispatch(&c, Action::Fullscreen)[0],
+            Command::SetWindowMode { id: wid(1), mode: TilingMode::Floating, locked: false }
+        );
+        // A record that itself says Fullscreen is useless: resolved-mode fallback.
+        c.windows[0].resolved_mode = TilingMode::Tiled;
+        c.windows[0].pre_fullscreen = Some((TilingMode::Fullscreen, true));
+        assert_eq!(
+            dispatch(&c, Action::Fullscreen)[0],
+            Command::SetWindowMode { id: wid(1), mode: TilingMode::Tiled, locked: false }
+        );
+    }
+
     #[test]
     fn mode_next_cycles_and_shared_hits_all_matching() {
         let mut c = ctx();
diff --git a/src/api.rs b/src/api.rs
index c9b8865..72e27f3 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -371,6 +371,12 @@ pub struct ActionWindow {
     /// (mechanism's `get_mode_for_window`) — what leaving Fullscreen
     /// falls back to.
     pub resolved_mode: TilingMode,
+    /// The mode and lock the window had when a `SetWindowMode` sent it
+    /// Fullscreen — what leaving Fullscreen restores, so a Tiled window
+    /// comes back Tiled. `None` when it is not Fullscreen, or got there by
+    /// another route (a client request, a rule); the exit then falls back
+    /// to `resolved_mode`.
+    pub pre_fullscreen: Option<(TilingMode, bool)>,
     /// Mapped and not in Closing/Init teardown/startup.
     pub visible: bool,
     /// In the focus-cycling set: currently rendered, not minimized, not a