ui: Dismiss context menus when window loses focus (#46866)

Jake Go and Danilo Leal created

Context menus close when the window becomes inactive (e.g., clicking to
another window or app). Previously, menus would remain visible until the
window regained focus.

This aligns Zed's context menu behavior with native macOS apps, where
NSMenu automatically dismisses when the window loses focus. Users expect
menus to close when switching windows or apps.

Uses `observe_window_activation` to detect when the window becomes
inactive and calls `cancel` to dismiss the menu.

Release Notes:

- Fixed context menus dismiss/cancel when the window loses focus

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>

Change summary

crates/ui/src/components/context_menu.rs | 30 ++++++++++++++++++++++++++
1 file changed, 30 insertions(+)

Detailed changes

crates/ui/src/components/context_menu.rs 🔗

@@ -220,6 +220,7 @@ pub struct ContextMenu {
     end_slot_action: Option<Box<dyn Action>>,
     key_context: SharedString,
     _on_blur_subscription: Subscription,
+    _on_window_deactivate_subscription: Subscription,
     keep_open_on_confirm: bool,
     fixed_width: Option<DefiniteLength>,
     main_menu: Option<Entity<ContextMenu>>,
@@ -295,6 +296,12 @@ impl ContextMenu {
                 this.cancel(&menu::Cancel, window, cx)
             },
         );
+        let _on_window_deactivate_subscription =
+            cx.observe_window_activation(window, |this: &mut ContextMenu, window, cx| {
+                if !window.is_window_active() {
+                    this.cancel(&menu::Cancel, window, cx);
+                }
+            });
         window.refresh();
 
         f(
@@ -309,6 +316,7 @@ impl ContextMenu {
                 end_slot_action: None,
                 key_context: "menu".into(),
                 _on_blur_subscription,
+                _on_window_deactivate_subscription,
                 keep_open_on_confirm: false,
                 fixed_width: None,
                 main_menu: None,
@@ -372,6 +380,12 @@ impl ContextMenu {
                     this.cancel(&menu::Cancel, window, cx)
                 },
             );
+            let _on_window_deactivate_subscription =
+                cx.observe_window_activation(window, |this: &mut ContextMenu, window, cx| {
+                    if !window.is_window_active() {
+                        this.cancel(&menu::Cancel, window, cx);
+                    }
+                });
             window.refresh();
 
             (builder.clone())(
@@ -386,6 +400,7 @@ impl ContextMenu {
                     end_slot_action: None,
                     key_context: "menu".into(),
                     _on_blur_subscription,
+                    _on_window_deactivate_subscription,
                     keep_open_on_confirm: true,
                     fixed_width: None,
                     main_menu: None,
@@ -455,6 +470,14 @@ impl ContextMenu {
                         this.cancel(&menu::Cancel, window, cx)
                     },
                 ),
+                _on_window_deactivate_subscription: cx.observe_window_activation(
+                    window,
+                    |this: &mut ContextMenu, window, cx| {
+                        if !window.is_window_active() {
+                            this.cancel(&menu::Cancel, window, cx);
+                        }
+                    },
+                ),
                 keep_open_on_confirm: false,
                 fixed_width: None,
                 main_menu: None,
@@ -1206,6 +1229,12 @@ impl ContextMenu {
                 window,
                 |_this: &mut ContextMenu, _window, _cx| {},
             );
+            let _on_window_deactivate_subscription =
+                cx.observe_window_activation(window, |this: &mut ContextMenu, window, cx| {
+                    if !window.is_window_active() {
+                        this.cancel(&menu::Cancel, window, cx);
+                    }
+                });
 
             let mut menu = ContextMenu {
                 builder: None,
@@ -1218,6 +1247,7 @@ impl ContextMenu {
                 end_slot_action: None,
                 key_context: "menu".into(),
                 _on_blur_subscription,
+                _on_window_deactivate_subscription,
                 keep_open_on_confirm: false,
                 fixed_width: None,
                 documentation_aside: None,