@@ -59,6 +59,10 @@ pub struct WmState { |
| 59 | 59 | /// matched against the lagging `NSWorkspace.frontmostApplication` poll |
| 60 | 60 | /// without the most recent entry clobbering older ones. |
| 61 | 61 | recent_internal_focus: Vec<(WindowId, i32, std::time::Instant)>, |
| 62 | + /// While set, drop incoming external-focus callbacks unconditionally. |
| 63 | + /// Armed on workspace switch to absorb the storm of frontmost-app |
| 64 | + /// transitions macOS reports while AX activations propagate. |
| 65 | + workspace_switch_silence_until: Option<std::time::Instant>, |
| 62 | 66 | drag: Option<DragState>, |
| 63 | 67 | pub focus_follows_mouse: bool, |
| 64 | 68 | pub mouse_follows_focus: bool, |
@@ -96,6 +100,7 @@ impl WmState { |
| 96 | 100 | ffm_last_window: None, |
| 97 | 101 | focus_return_memory: HashMap::new(), |
| 98 | 102 | recent_internal_focus: Vec::new(), |
| 103 | + workspace_switch_silence_until: None, |
| 99 | 104 | drag: None, |
| 100 | 105 | focus_follows_mouse: true, |
| 101 | 106 | mouse_follows_focus: true, |
@@ -973,6 +978,28 @@ impl WmState { |
| 973 | 978 | )); |
| 974 | 979 | } |
| 975 | 980 | |
| 981 | + /// How long after a workspace switch to drop external-focus callbacks. |
| 982 | + /// Covers the apply_layout 50ms sleep, AX activation propagation, and |
| 983 | + /// several frontmost-app poll cycles before the OS state settles. |
| 984 | + const WORKSPACE_SWITCH_SILENCE: std::time::Duration = |
| 985 | + std::time::Duration::from_millis(400); |
| 986 | + |
| 987 | + fn arm_workspace_switch_silence(&mut self) { |
| 988 | + self.workspace_switch_silence_until = |
| 989 | + Some(std::time::Instant::now() + Self::WORKSPACE_SWITCH_SILENCE); |
| 990 | + } |
| 991 | + |
| 992 | + fn is_in_workspace_switch_silence(&mut self) -> bool { |
| 993 | + let Some(until) = self.workspace_switch_silence_until else { |
| 994 | + return false; |
| 995 | + }; |
| 996 | + if std::time::Instant::now() >= until { |
| 997 | + self.workspace_switch_silence_until = None; |
| 998 | + return false; |
| 999 | + } |
| 1000 | + true |
| 1001 | + } |
| 1002 | + |
| 976 | 1003 | fn should_ignore_external_focus(&mut self, pid: i32, requested: Option<WindowId>) -> bool { |
| 977 | 1004 | self.prune_recent_internal_focus(); |
| 978 | 1005 | if self.recent_internal_focus.is_empty() { |
@@ -1109,6 +1136,9 @@ impl WmState { |
| 1109 | 1136 | } |
| 1110 | 1137 | |
| 1111 | 1138 | pub fn adopt_external_app_focus(&mut self, pid: i32, requested: Option<WindowId>) { |
| 1139 | + if self.is_in_workspace_switch_silence() { |
| 1140 | + return; |
| 1141 | + } |
| 1112 | 1142 | if self.should_ignore_external_focus(pid, requested) { |
| 1113 | 1143 | return; |
| 1114 | 1144 | } |
@@ -1620,6 +1650,13 @@ impl WmState { |
| 1620 | 1650 | |
| 1621 | 1651 | tracing::info!(from = current_idx + 1, to = %target, "switching workspace"); |
| 1622 | 1652 | |
| 1653 | + // Drop external-focus callbacks for a beat. While AX activations |
| 1654 | + // propagate and apply_layout settles, NSWorkspace.frontmostApplication |
| 1655 | + // can flip back through the previously-focused app and the 50ms poll |
| 1656 | + // would otherwise interpret it as an external focus event and yank us |
| 1657 | + // back across workspaces. |
| 1658 | + self.arm_workspace_switch_silence(); |
| 1659 | + |
| 1623 | 1660 | // Case 1: Target workspace is already visible on some monitor → jump focus |
| 1624 | 1661 | if let Some(other_mi) = self.monitor_showing_workspace(target_idx) { |
| 1625 | 1662 | self.focused_monitor = other_mi; |
@@ -3725,6 +3762,47 @@ mod tests { |
| 3725 | 3762 | ); |
| 3726 | 3763 | } |
| 3727 | 3764 | |
| 3765 | + #[test] |
| 3766 | + fn workspace_switch_silences_external_app_focus_callback() { |
| 3767 | + // Regression: a frontmost-app poll that arrives mid workspace switch |
| 3768 | + // (with a stale pid that doesn't match the ring) used to switch us |
| 3769 | + // back across workspaces, kicking off the rapid-cycling loop. |
| 3770 | + let mut state = WmState::new(); |
| 3771 | + state.monitors = vec![Monitor { |
| 3772 | + id: 42, |
| 3773 | + frame: Rect::new(0.0, 0.0, 1920.0, 1080.0), |
| 3774 | + usable_frame: Rect::new(0.0, 33.0, 1920.0, 1047.0), |
| 3775 | + is_primary: true, |
| 3776 | + active_workspace: 0, |
| 3777 | + }]; |
| 3778 | + let screen = state.monitors[0].usable_frame; |
| 3779 | + |
| 3780 | + state.registry.add(tracked_window(90, 21, "AppA")); |
| 3781 | + state.registry.add(tracked_window(91, 22, "AppB")); |
| 3782 | + state |
| 3783 | + .workspaces |
| 3784 | + .get_or_create_target(&WorkspaceTarget::Numbered(2)); |
| 3785 | + { |
| 3786 | + let ws = state.workspaces.get_mut(0); |
| 3787 | + ws.tree.insert_with_rect(90, None, screen); |
| 3788 | + ws.record_focus(90); |
| 3789 | + } |
| 3790 | + { |
| 3791 | + let ws = state.workspaces.get_mut(1); |
| 3792 | + ws.tree.insert_with_rect(91, None, screen); |
| 3793 | + ws.record_focus(91); |
| 3794 | + } |
| 3795 | + state.sync_workspace_visibility(); |
| 3796 | + |
| 3797 | + state.switch_workspace(&WorkspaceTarget::Numbered(2)); |
| 3798 | + assert_eq!(state.monitors[0].active_workspace, 1); |
| 3799 | + |
| 3800 | + // Lagging poll for the previous workspace's frontmost app — must not |
| 3801 | + // yank us back to ws1 while the silence latch is armed. |
| 3802 | + state.adopt_external_app_focus(21, None); |
| 3803 | + assert_eq!(state.monitors[0].active_workspace, 1); |
| 3804 | + } |
| 3805 | + |
| 3728 | 3806 | #[test] |
| 3729 | 3807 | fn recent_internal_focus_ignores_lagging_poll_for_prior_activation() { |
| 3730 | 3808 | // Regression: the single-slot guard let the *previous* activation's |