1 /* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
2 /* This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
7 * Makes sure that the nested event loop for NSMenu tracking is situated as low
8 * on the stack as possible, and that two NSMenu event loops are never nested.
11 #include "MOZMenuOpeningCoordinator.h"
13 #include "mozilla/ClearOnShutdown.h"
14 #include "mozilla/StaticPrefs_widget.h"
16 #include "nsCocoaFeatures.h"
17 #include "nsCocoaUtils.h"
19 #include "nsObjCExceptions.h"
21 static BOOL sNeedToUnwindForMenuClosing = NO;
23 @interface MOZMenuOpeningInfo : NSObject
24 @property NSInteger handle;
25 @property(retain) NSMenu* menu;
26 @property NSPoint position;
27 @property(retain) NSView* view;
28 @property(retain) NSAppearance* appearance;
29 @property BOOL isContextMenu;
32 @implementation MOZMenuOpeningInfo
35 @implementation MOZMenuOpeningCoordinator {
36 // non-nil between asynchronouslyOpenMenu:atScreenPosition:forView: and the
37 // time at at which it is unqueued in _runMenu.
38 MOZMenuOpeningInfo* mPendingOpening; // strong
40 // An incrementing counter
41 NSInteger mLastHandle;
43 // YES while _runMenu is on the stack
44 BOOL mRunMenuIsOnTheStack;
47 + (instancetype)sharedInstance {
48 static MOZMenuOpeningCoordinator* sInstance = nil;
50 sInstance = [[MOZMenuOpeningCoordinator alloc] init];
51 mozilla::RunOnShutdown([&]() {
60 MOZ_RELEASE_ASSERT(!mPendingOpening, "should be empty at shutdown");
64 - (NSInteger)asynchronouslyOpenMenu:(NSMenu*)aMenu
65 atScreenPosition:(NSPoint)aPosition
66 forView:(NSView*)aView
67 withAppearance:(NSAppearance*)aAppearance
68 asContextMenu:(BOOL)aIsContextMenu {
69 MOZ_RELEASE_ASSERT(!mPendingOpening,
70 "A menu is already waiting to open. Before opening the "
71 "next one, either wait "
72 "for this one to open or cancel the request.");
74 NSInteger handle = ++mLastHandle;
76 MOZMenuOpeningInfo* info = [[MOZMenuOpeningInfo alloc] init];
79 info.position = aPosition;
81 info.appearance = aAppearance;
82 info.isContextMenu = aIsContextMenu;
83 mPendingOpening = [info retain];
86 if (!mRunMenuIsOnTheStack) {
87 // Call _runMenu from the event loop, so that it doesn't block this call.
88 [self performSelector:@selector(_runMenu) withObject:nil afterDelay:0.0];
95 MOZ_RELEASE_ASSERT(!mRunMenuIsOnTheStack);
97 mRunMenuIsOnTheStack = YES;
99 while (mPendingOpening) {
100 MOZMenuOpeningInfo* info = [mPendingOpening retain];
101 [mPendingOpening release];
102 mPendingOpening = nil;
105 [self _openMenu:info.menu
106 atScreenPosition:info.position
108 withAppearance:info.appearance
109 asContextMenu:info.isContextMenu];
110 } @catch (NSException* exception) {
111 nsObjCExceptionLog(exception);
116 // We have exited _openMenu's nested event loop.
117 MOZMenuOpeningCoordinator.needToUnwindForMenuClosing = NO;
120 mRunMenuIsOnTheStack = NO;
123 - (void)cancelAsynchronousOpening:(NSInteger)aHandle {
124 if (mPendingOpening && mPendingOpening.handle == aHandle) {
125 [mPendingOpening release];
126 mPendingOpening = nil;
130 - (void)_openMenu:(NSMenu*)aMenu
131 atScreenPosition:(NSPoint)aPosition
132 forView:(NSView*)aView
133 withAppearance:(NSAppearance*)aAppearance
134 asContextMenu:(BOOL)aIsContextMenu {
135 // There are multiple ways to display an NSMenu as a context menu.
137 // 1. We can return the NSMenu from -[ChildView menuForEvent:] and the NSView
138 // will open it for us.
139 // 2. We can call +[NSMenu popUpContextMenu:withEvent:forView:] inside a
140 // mouseDown handler with a real mouse down event.
141 // 3. We can call +[NSMenu popUpContextMenu:withEvent:forView:] at a later
142 // time, with a real mouse event that we stored earlier.
143 // 4. We can call +[NSMenu popUpContextMenu:withEvent:forView:] at any time,
144 // with a synthetic mouse event that we create just for that purpose.
145 // 5. We can call -[NSMenu popUpMenuPositioningItem:atLocation:inView:] and
146 // it just takes a position, not an event.
148 // 1-4 look the same, 5 looks different: 5 is made for use with NSPopUpButton,
149 // where the selected item needs to be shown at a specific position. If a tall
150 // menu is opened with a position close to the bottom edge of the screen, 5
151 // results in a cropped menu with scroll arrows, even if the entire menu would
152 // fit on the screen, due to the positioning constraint. 1-2 only work if the
153 // menu contents are known synchronously during the call to menuForEvent or
154 // during the mouseDown event handler.
155 // NativeMenuMac::ShowAsContextMenu can be called at any time. It could be
156 // called during a menuForEvent call (during a "contextmenu" event handler),
157 // or during a mouseDown handler, or at a later time. The code below uses
158 // option 4 as the preferred option for context menus because it's the
159 // simplest: It works in all scenarios and it doesn't have the drawbacks of
160 // option 5. For popups that aren't context menus and that should be
161 // positioned as close as possible to the given screen position, we use
165 if (@available(macOS 11.0, *)) {
166 // By default, NSMenu inherits its appearance from the opening NSEvent's
167 // window. If CSS has overridden it, on Big Sur + we can respect it with
168 // -[NSMenu setAppearance].
169 aMenu.appearance = aAppearance;
174 NSWindow* window = aView.window;
175 NSPoint locationInWindow =
176 nsCocoaUtils::ConvertPointFromScreen(window, aPosition);
177 if (aIsContextMenu) {
178 // Create a synthetic event at the right location and open the menu
181 [NSEvent mouseEventWithType:NSEventTypeRightMouseDown
182 location:locationInWindow
184 timestamp:NSProcessInfo.processInfo.systemUptime
185 windowNumber:window.windowNumber
190 [NSMenu popUpContextMenu:aMenu withEvent:event forView:aView];
192 // For popups which are not context menus, we open the menu using [option
193 // 5]. We pass `nil` to indicate that we're positioning the top left
194 // corner of the menu. This path is used for anchored menupopups, so we
195 // prefer option 5 over option 4 so that the menu doesn't get flipped if
197 NSPoint locationInView = [aView convertPoint:locationInWindow
199 [aMenu popUpMenuPositioningItem:nil
200 atLocation:locationInView
204 // Open the menu using popUpMenuPositioningItem:atLocation:inView: [option
205 // 5]. This is not preferred, because it positions the menu differently from
206 // how a native context menu would be positioned; it enforces aPosition for
207 // the top left corner even if this means that the menu will be displayed in
208 // a clipped fashion with scroll arrows.
209 [aMenu popUpMenuPositioningItem:nil atLocation:aPosition inView:nil];
213 + (void)setNeedToUnwindForMenuClosing:(BOOL)aValue {
214 sNeedToUnwindForMenuClosing = aValue;
217 + (BOOL)needToUnwindForMenuClosing {
218 return sNeedToUnwindForMenuClosing;