gardesk/tarmac / c59235c

Browse files

Silence external-focus callbacks during workspace switch

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
c59235c5f8d15409be3172360dbbbc26ce9a2a9e
Parents
ee3fd8e
Tree
ea1afcf

1 changed file

StatusFile+-
M tarmac/src/core/state.rs 78 0
tarmac/src/core/state.rsmodified
@@ -59,6 +59,10 @@ pub struct WmState {
5959
     /// matched against the lagging `NSWorkspace.frontmostApplication` poll
6060
     /// without the most recent entry clobbering older ones.
6161
     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>,
6266
     drag: Option<DragState>,
6367
     pub focus_follows_mouse: bool,
6468
     pub mouse_follows_focus: bool,
@@ -96,6 +100,7 @@ impl WmState {
96100
             ffm_last_window: None,
97101
             focus_return_memory: HashMap::new(),
98102
             recent_internal_focus: Vec::new(),
103
+            workspace_switch_silence_until: None,
99104
             drag: None,
100105
             focus_follows_mouse: true,
101106
             mouse_follows_focus: true,
@@ -973,6 +978,28 @@ impl WmState {
973978
         ));
974979
     }
975980
 
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
+
9761003
     fn should_ignore_external_focus(&mut self, pid: i32, requested: Option<WindowId>) -> bool {
9771004
         self.prune_recent_internal_focus();
9781005
         if self.recent_internal_focus.is_empty() {
@@ -1109,6 +1136,9 @@ impl WmState {
11091136
     }
11101137
 
11111138
     pub fn adopt_external_app_focus(&mut self, pid: i32, requested: Option<WindowId>) {
1139
+        if self.is_in_workspace_switch_silence() {
1140
+            return;
1141
+        }
11121142
         if self.should_ignore_external_focus(pid, requested) {
11131143
             return;
11141144
         }
@@ -1620,6 +1650,13 @@ impl WmState {
16201650
 
16211651
         tracing::info!(from = current_idx + 1, to = %target, "switching workspace");
16221652
 
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
+
16231660
         // Case 1: Target workspace is already visible on some monitor → jump focus
16241661
         if let Some(other_mi) = self.monitor_showing_workspace(target_idx) {
16251662
             self.focused_monitor = other_mi;
@@ -3725,6 +3762,47 @@ mod tests {
37253762
         );
37263763
     }
37273764
 
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
+
37283806
     #[test]
37293807
     fn recent_internal_focus_ignores_lagging_poll_for_prior_activation() {
37303808
         // Regression: the single-slot guard let the *previous* activation's