diff --git a/src/backend/x11/render.rs b/src/backend/x11/render.rs index a7c1ba7..41a9242 100644 --- a/src/backend/x11/render.rs +++ b/src/backend/x11/render.rs @@ -796,6 +796,29 @@ impl WindowManager { if self.has_protocol(w, self.atoms.wm_take_focus)? { self.send_proto(w, self.atoms.wm_take_focus, self.last_event_time)?; } + // Commit the logical focus to `State` *before* reconciling against the + // real X input focus below. `reconcile_focus()` compares the real X + // focus (`get_input_focus`) to `mon.focused`; if `mon.focused` were + // only written *after* that compare (its previous position, near the + // end of this function), a focus request whose caller never updated + // `mon.focused` first would leave it naming the window that was + // focused on the just-left workspace. `ViewWorkspace` is the prime + // example: it emits `FocusWindow(best_focus(mi))` but does not write + // `mon.focused` itself (unlike `FocusDirection`). `reconcile_focus` + // would then see logical != real and re-assert input focus onto that + // previous, now hidden-but-viewable window — desyncing real focus from + // the visible window (the reported "focus lost after returning to a + // workspace" bug, only recoverable with h/l). Writing `mon.focused` + // here makes logical == real for our own focus request, so the + // reconcile is a no-op and the real focus stays on the intended + // window. This matches the ordering already used by the `focus(None)` + // branch, which writes `mon.focused` before its `reconcile_focus`. + { + let mon = &mut self.engine.state.monitors[mon_i]; + mon.focused = Some(w); + mon.focus_stack.retain(|&x| x != w); + mon.focus_stack.push(w); + } // Verify the server accepted the focus (and fix it if an external // XSetInputFocus raced us). No polling: this runs only on a focus // action we just issued. @@ -830,16 +853,11 @@ impl WindowManager { self.write_net_wm_state(w); } - let mon = &mut self.engine.state.monitors[mon_i]; - mon.focused = Some(w); - mon.focus_stack.retain(|&x| x != w); - mon.focus_stack.push(w); - #[cfg(feature = "input-trace")] itrace!( "focus() SET mon[{}].focused={:?} (was {:?}); x11_input_focus={:?}", mon_i, - mon.focused, + self.engine.state.monitors[mon_i].focused, prev_focused, self.engine.state.x11_input_focus ); diff --git a/src/core/tests.rs b/src/core/tests.rs index 956f3ca..9694f3e 100644 --- a/src/core/tests.rs +++ b/src/core/tests.rs @@ -1965,6 +1965,105 @@ mod unit_tests { engine.state.monitors[mi].workspaces[0].layout = LayoutKind::Grid; assert_eq!(engine.state.best_focus(mi), Some(1)); } + + // ─── Bug: workspace switch must keep the focused window Maverick considers + // focused (lost keyboard focus on return) ────────────────────────────── + // + // Repro of the reported bug: Alacritty focused on ws0, switch to ws1, switch + // back to ws0 — Alacritty is visible again but the real X input focus is gone + // until `h`/`l` is pressed. The state-level invariant this test locks: the + // window Maverick *considers* focused (`best_focus`, which `ViewWorkspace` + // uses to pick its `FocusWindow` target) must survive the trip away and back, + // and `ViewWorkspace` must keep emitting `FocusWindow` for that same window. + // + // The actual desync is in the X11 backend: `Backend::focus` set the real X + // input focus and then ran `reconcile_focus()` *before* committing the + // logical `mon.focused`, so when a command (like `ViewWorkspace`) did not + // pre-write `mon.focused` the reconcile re-asserted focus onto the + // previously-focused, now-hidden window (fixed in `backend/x11/render.rs` by + // committing `mon.focused` before `reconcile_focus`). This state/effect test + // guards the core contract that fix depends on; the X-level reconciliation + // itself is validated under Xephyr via the `input-trace` diagnostics. + #[test] + fn view_workspace_round_trip_keeps_focused_window() { + use crate::core::commands::ViewWorkspace; + use crate::core::effect::Effect; + use crate::types::{Client, Column, Focus}; + + let mut engine = setup_engine(); + let mi = engine.state.sel_mon; + + // ws0: Alacritty (1) focused, plus a neighbour (2). + { + let ws = &mut engine.state.monitors[mi].workspaces[0]; + ws.columns.push(Column { + windows: vec![1], + focused: 0, + weight: 0.5, + boost: 1.0, + }); + ws.columns.push(Column { + windows: vec![2], + focused: 0, + weight: 0.5, + boost: 1.0, + }); + ws.focus = Focus { column_idx: 0 }; + } + engine.state.add_client(Client::new(1, mi, 0)); + engine.state.add_client(Client::new(2, mi, 0)); + + // ws1: a different window (3) of its own. + { + let ws = &mut engine.state.monitors[mi].workspaces[1]; + ws.columns.push(Column { + windows: vec![3], + focused: 0, + weight: 1.0, + boost: 1.0, + }); + ws.focus = Focus { column_idx: 0 }; + } + engine.state.add_client(Client::new(3, mi, 1)); + + engine.state.monitors[mi].focused = Some(1); + engine.state.monitors[mi].focus_stack = vec![1, 2]; + + // Alacritty is the window Maverick considers focused on ws0. + assert_eq!(engine.state.best_focus(mi), Some(1)); + + // Switch to ws1. + let eff1 = engine.execute(ViewWorkspace(1)); + let fw1 = eff1 + .iter() + .rev() + .find_map(|e| match e { + Effect::FocusWindow(w) => Some(*w), + _ => None, + }) + .expect("ViewWorkspace(1) must emit FocusWindow"); + assert_eq!(fw1, Some(3), "ws1's only window must be focused on switch"); + + // Switch back to ws0 — Alacritty must still be the focused window and the + // effect that re-syncs focus must target it. + let eff0 = engine.execute(ViewWorkspace(0)); + let fw0 = eff0 + .iter() + .rev() + .find_map(|e| match e { + Effect::FocusWindow(w) => Some(*w), + _ => None, + }) + .expect("ViewWorkspace(0) must emit FocusWindow"); + assert_eq!(fw0, Some(1), "returning to ws0 must re-focus Alacritty"); + assert_eq!( + engine.state.best_focus(mi), + Some(1), + "the focused window Maverick considers focused must survive the \ + workspace round trip (precondition for the X backend to keep real \ + input focus on the visible window)" + ); + } // ── GrowColumn clamp panic regression (bug C2) ────────────────────────────── // // With many columns the old `1.0 - 0.05*(n-1)` upper bound drops below the