@@ -53,7 +53,12 @@ pub struct WmState { |
| 53 | 53 | ffm_cooldown_until: Option<std::time::Instant>, |
| 54 | 54 | ffm_last_window: Option<WindowId>, |
| 55 | 55 | focus_return_memory: HashMap<(WindowId, super::tree::Direction), WindowId>, |
| 56 | | - pending_internal_focus: Option<(WindowId, i32, std::time::Instant)>, |
| 56 | + /// Recent intentional focus activations (window id, app pid, expiry). |
| 57 | + /// Acts as a small ring so a burst of internal activations (e.g. a |
| 58 | + /// workspace switch followed by a follow-up FFM focus) can all be |
| 59 | + /// matched against the lagging `NSWorkspace.frontmostApplication` poll |
| 60 | + /// without the most recent entry clobbering older ones. |
| 61 | + recent_internal_focus: Vec<(WindowId, i32, std::time::Instant)>, |
| 57 | 62 | drag: Option<DragState>, |
| 58 | 63 | pub focus_follows_mouse: bool, |
| 59 | 64 | pub mouse_follows_focus: bool, |
@@ -90,7 +95,7 @@ impl WmState { |
| 90 | 95 | ffm_cooldown_until: None, |
| 91 | 96 | ffm_last_window: None, |
| 92 | 97 | focus_return_memory: HashMap::new(), |
| 93 | | - pending_internal_focus: None, |
| 98 | + recent_internal_focus: Vec::new(), |
| 94 | 99 | drag: None, |
| 95 | 100 | focus_follows_mouse: true, |
| 96 | 101 | mouse_follows_focus: true, |
@@ -928,30 +933,60 @@ impl WmState { |
| 928 | 933 | .retain(|(from, _), to| *from != window && *to != window); |
| 929 | 934 | } |
| 930 | 935 | |
| 936 | + /// Cap on `recent_internal_focus` ring size. Bounds memory and bounds the |
| 937 | + /// linear scan in `should_ignore_external_focus`. A workspace switch |
| 938 | + /// produces ~1 internal focus per visible window, so 16 is plenty. |
| 939 | + const RECENT_FOCUS_CAPACITY: usize = 16; |
| 940 | + /// How long an entry in `recent_internal_focus` shadows incoming external |
| 941 | + /// focus events. Long enough to cover apply_layout's 50ms sleep plus AX |
| 942 | + /// activation propagation and at least one frontmost-app poll cycle. |
| 943 | + const RECENT_FOCUS_TTL: std::time::Duration = std::time::Duration::from_millis(600); |
| 944 | + |
| 945 | + fn prune_recent_internal_focus(&mut self) { |
| 946 | + let now = std::time::Instant::now(); |
| 947 | + self.recent_internal_focus.retain(|(_, _, until)| now < *until); |
| 948 | + } |
| 949 | + |
| 931 | 950 | fn mark_internal_focus(&mut self, id: WindowId) { |
| 932 | 951 | let Some(window) = self.registry.get(id) else { |
| 933 | 952 | return; |
| 934 | 953 | }; |
| 935 | | - self.pending_internal_focus = Some(( |
| 954 | + let pid = window.app_pid; |
| 955 | + self.prune_recent_internal_focus(); |
| 956 | + // Coalesce: if the same window is already pending, just refresh its |
| 957 | + // expiry rather than appending a duplicate. |
| 958 | + if let Some(entry) = self |
| 959 | + .recent_internal_focus |
| 960 | + .iter_mut() |
| 961 | + .find(|(eid, epid, _)| *eid == id && *epid == pid) |
| 962 | + { |
| 963 | + entry.2 = std::time::Instant::now() + Self::RECENT_FOCUS_TTL; |
| 964 | + return; |
| 965 | + } |
| 966 | + if self.recent_internal_focus.len() >= Self::RECENT_FOCUS_CAPACITY { |
| 967 | + self.recent_internal_focus.remove(0); |
| 968 | + } |
| 969 | + self.recent_internal_focus.push(( |
| 936 | 970 | id, |
| 937 | | - window.app_pid, |
| 938 | | - std::time::Instant::now() + std::time::Duration::from_millis(400), |
| 971 | + pid, |
| 972 | + std::time::Instant::now() + Self::RECENT_FOCUS_TTL, |
| 939 | 973 | )); |
| 940 | 974 | } |
| 941 | 975 | |
| 942 | 976 | fn should_ignore_external_focus(&mut self, pid: i32, requested: Option<WindowId>) -> bool { |
| 943 | | - let Some((pending_id, pending_pid, until)) = self.pending_internal_focus else { |
| 944 | | - return false; |
| 945 | | - }; |
| 946 | | - if std::time::Instant::now() >= until { |
| 947 | | - self.pending_internal_focus = None; |
| 977 | + self.prune_recent_internal_focus(); |
| 978 | + if self.recent_internal_focus.is_empty() { |
| 948 | 979 | return false; |
| 949 | 980 | } |
| 950 | | - |
| 951 | 981 | match requested { |
| 952 | | - Some(id) if id == pending_id => true, |
| 953 | | - None if pid == pending_pid => true, |
| 954 | | - _ => false, |
| 982 | + Some(id) => self |
| 983 | + .recent_internal_focus |
| 984 | + .iter() |
| 985 | + .any(|(eid, _, _)| *eid == id), |
| 986 | + None => self |
| 987 | + .recent_internal_focus |
| 988 | + .iter() |
| 989 | + .any(|(_, epid, _)| *epid == pid), |
| 955 | 990 | } |
| 956 | 991 | } |
| 957 | 992 | |
@@ -3690,6 +3725,45 @@ mod tests { |
| 3690 | 3725 | ); |
| 3691 | 3726 | } |
| 3692 | 3727 | |
| 3728 | + #[test] |
| 3729 | + fn recent_internal_focus_ignores_lagging_poll_for_prior_activation() { |
| 3730 | + // Regression: the single-slot guard let the *previous* activation's |
| 3731 | + // pid leak through once a newer activation overwrote the slot. With |
| 3732 | + // a ring, a frontmost-app poll arriving for the older pid should |
| 3733 | + // still be recognized as self-initiated. |
| 3734 | + let mut state = WmState::new(); |
| 3735 | + state.monitors = vec![Monitor { |
| 3736 | + id: 42, |
| 3737 | + frame: Rect::new(0.0, 0.0, 1920.0, 1080.0), |
| 3738 | + usable_frame: Rect::new(0.0, 33.0, 1920.0, 1047.0), |
| 3739 | + is_primary: true, |
| 3740 | + active_workspace: 0, |
| 3741 | + }]; |
| 3742 | + state.sync_workspace_visibility(); |
| 3743 | + let screen = state.monitors[0].usable_frame; |
| 3744 | + |
| 3745 | + state.registry.add(tracked_window(80, 14, "AppA")); |
| 3746 | + state.registry.add(tracked_window(81, 15, "AppB")); |
| 3747 | + { |
| 3748 | + let ws = state.workspaces.get_mut(0); |
| 3749 | + ws.tree.insert_with_rect(80, None, screen); |
| 3750 | + ws.tree.insert_with_rect(81, Some(80), screen); |
| 3751 | + ws.record_focus(80); |
| 3752 | + } |
| 3753 | + |
| 3754 | + // Two intentional activations in quick succession — single-slot would |
| 3755 | + // forget the first one. |
| 3756 | + state.focus_window(80); |
| 3757 | + state.focus_window(81); |
| 3758 | + |
| 3759 | + // A lagging poll arrives for the older activation's pid. |
| 3760 | + assert!(state.should_ignore_external_focus(14, None)); |
| 3761 | + // And for the newer one. |
| 3762 | + assert!(state.should_ignore_external_focus(15, None)); |
| 3763 | + // Unrelated pid still passes through. |
| 3764 | + assert!(!state.should_ignore_external_focus(99, None)); |
| 3765 | + } |
| 3766 | + |
| 3693 | 3767 | #[test] |
| 3694 | 3768 | fn internal_focus_echo_does_not_trigger_external_mouse_warp() { |
| 3695 | 3769 | let mut state = WmState::new(); |