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/. */
5
6/*
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.
9 */
10
11#include "MOZMenuOpeningCoordinator.h"
12
13#include "mozilla/ClearOnShutdown.h"
14#include "mozilla/StaticPrefs_widget.h"
15
16#include "nsCocoaFeatures.h"
17#include "nsCocoaUtils.h"
18#include "nsDeque.h"
19#include "nsMenuX.h"
20#include "nsObjCExceptions.h"
21#include "nsThreadUtils.h"
22#include "SDKDeclarations.h"
23
24static BOOL sNeedToUnwindForMenuClosing = NO;
25
26@interface MOZMenuOpeningInfo : NSObject
27@property NSInteger handle;
28@property(retain) NSMenu* menu;
29@property NSPoint position;
30@property(retain) NSView* view;
31@end
32
33@implementation MOZMenuOpeningInfo
34@end
35
36@implementation MOZMenuOpeningCoordinator {
37  // non-nil between asynchronouslyOpenMenu:atScreenPosition:forView: and the
38  // time at at which it is unqueued in _runMenu.
39  MOZMenuOpeningInfo* mPendingOpening;  // strong
40
41  // Any runnables we want to run after the current menu event loop has been exited.
42  // Only non-empty if mRunMenuIsOnTheStack is true.
43  nsRefPtrDeque<mozilla::Runnable> mPendingAfterMenuCloseRunnables;
44
45  // An incrementing counter
46  NSInteger mLastHandle;
47
48  // YES while _runMenu is on the stack
49  BOOL mRunMenuIsOnTheStack;
50}
51
52+ (instancetype)sharedInstance {
53  static MOZMenuOpeningCoordinator* sInstance = nil;
54  if (!sInstance) {
55    sInstance = [[MOZMenuOpeningCoordinator alloc] init];
56    mozilla::RunOnShutdown([&]() {
57      [sInstance release];
58      sInstance = nil;
59    });
60  }
61  return sInstance;
62}
63
64- (void)dealloc {
65  MOZ_RELEASE_ASSERT(!mPendingOpening, "should be empty at shutdown");
66  MOZ_RELEASE_ASSERT(mPendingAfterMenuCloseRunnables.GetSize() == 0, "should be empty at shutdown");
67  [super dealloc];
68}
69
70- (NSInteger)asynchronouslyOpenMenu:(NSMenu*)aMenu
71                   atScreenPosition:(NSPoint)aPosition
72                            forView:(NSView*)aView {
73  MOZ_RELEASE_ASSERT(!mPendingOpening,
74                     "A menu is already waiting to open. Before opening the next one, either wait "
75                     "for this one to open or cancel the request.");
76
77  NSInteger handle = ++mLastHandle;
78
79  MOZMenuOpeningInfo* info = [[MOZMenuOpeningInfo alloc] init];
80  info.handle = handle;
81  info.menu = aMenu;
82  info.position = aPosition;
83  info.view = aView;
84  mPendingOpening = [info retain];
85  [info release];
86
87  if (!mRunMenuIsOnTheStack) {
88    // Call _runMenu from the event loop, so that it doesn't block this call.
89    [self performSelector:@selector(_runMenu) withObject:nil afterDelay:0.0];
90  }
91
92  return handle;
93}
94
95- (void)_runMenu {
96  MOZ_RELEASE_ASSERT(!mRunMenuIsOnTheStack);
97
98  mRunMenuIsOnTheStack = YES;
99
100  while (mPendingOpening) {
101    MOZMenuOpeningInfo* info = [mPendingOpening retain];
102    [mPendingOpening release];
103    mPendingOpening = nil;
104
105    @try {
106      [self _openMenu:info.menu atScreenPosition:info.position forView:info.view];
107    } @catch (NSException* exception) {
108      nsObjCExceptionLog(exception);
109    }
110
111    [info release];
112
113    // We have exited _openMenu's nested event loop.
114    MOZMenuOpeningCoordinator.needToUnwindForMenuClosing = NO;
115
116    // Dispatch any pending "after menu close" runnables to the event loop.
117    while (mPendingAfterMenuCloseRunnables.GetSize() != 0) {
118      NS_DispatchToCurrentThread(mPendingAfterMenuCloseRunnables.PopFront());
119    }
120  }
121
122  mRunMenuIsOnTheStack = NO;
123}
124
125- (void)cancelAsynchronousOpening:(NSInteger)aHandle {
126  if (mPendingOpening && mPendingOpening.handle == aHandle) {
127    [mPendingOpening release];
128    mPendingOpening = nil;
129  }
130}
131
132- (void)runAfterMenuClosed:(RefPtr<mozilla::Runnable>&&)aRunnable {
133  MOZ_RELEASE_ASSERT(aRunnable);
134
135  if (mRunMenuIsOnTheStack) {
136    mPendingAfterMenuCloseRunnables.Push(aRunnable.forget());
137  } else {
138    NS_DispatchToCurrentThread(aRunnable.forget());
139  }
140}
141
142- (void)_openMenu:(NSMenu*)aMenu atScreenPosition:(NSPoint)aPosition forView:(NSView*)aView {
143  // There are multiple ways to display an NSMenu as a context menu.
144  //
145  //  1. We can return the NSMenu from -[ChildView menuForEvent:] and the NSView will open it for
146  //     us.
147  //  2. We can call +[NSMenu popUpContextMenu:withEvent:forView:] inside a mouseDown handler with a
148  //     real mouse down event.
149  //  3. We can call +[NSMenu popUpContextMenu:withEvent:forView:] at a later time, with a real
150  //     mouse event that we stored earlier.
151  //  4. We can call +[NSMenu popUpContextMenu:withEvent:forView:] at any time, with a synthetic
152  //     mouse event that we create just for that purpose.
153  //  5. We can call -[NSMenu popUpMenuPositioningItem:atLocation:inView:] and it just takes a
154  //     position, not an event.
155  //
156  // 1-4 look the same, 5 looks different: 5 is made for use with NSPopUpButton, where the selected
157  // item needs to be shown at a specific position. If a tall menu is opened with a position close
158  // to the bottom edge of the screen, 5 results in a cropped menu with scroll arrows, even if the
159  // entire menu would fit on the screen, due to the positioning constraint.
160  // 1-2 only work if the menu contents are known synchronously during the call to menuForEvent or
161  // during the mouseDown event handler.
162  // NativeMenuMac::ShowAsContextMenu can be called at any time. It could be called during a
163  // menuForEvent call (during a "contextmenu" event handler), or during a mouseDown handler, or at
164  // a later time.
165  // The code below uses option 4 as the preferred option because it's the simplest: It works in all
166  // scenarios and it doesn't have the positioning drawbacks of option 5.
167
168  if (aView) {
169    NSWindow* window = aView.window;
170
171    if (@available(macOS 10.14, *)) {
172      if (window.effectiveAppearance != NSApp.effectiveAppearance) {
173        // By default, NSMenu inherits its appearance from the opening NSEvent's window. But we
174        // would like it to use the system appearance - if the system uses Dark Mode, we would like
175        // context menus to be dark even if the window's appearance is Light.
176#if !defined(MAC_OS_VERSION_11_0) || MAC_OS_X_VERSION_MAX_ALLOWED < MAC_OS_VERSION_11_0
177        if (nsCocoaFeatures::OnBigSurOrLater()) {
178#else
179        if (@available(macOS 11.0, *)) {
180#endif
181          // On macOS Big Sur, we can achieve this by using -[NSMenu setAppearance:].
182          [aMenu setAppearance:NSApp.effectiveAppearance];
183        } else if (mozilla::StaticPrefs::
184                       widget_macos_enable_pre_bigsur_workaround_for_dark_mode_context_menus()) {
185          // On 10.14 and 10.15, there is no API to override the NSMenu appearance.
186          // We use the following hack: We change the NSWindow appearance just long enough that
187          // NSMenu opening picks it up, and then reset the NSWindow appearance to its old value.
188          // Resetting it in the menu delegate's menuWillOpen method seems to achieve this without
189          // any flashing effect.
190          if ([aMenu.delegate isKindOfClass:[MenuDelegate class]]) {
191            MenuDelegate* delegate = (MenuDelegate*)aMenu.delegate;
192
193            // Store the old NSWindow appearance, override it with the system appearance, and then
194            // reset it when the menu is open.
195            NSAppearance* oldAppearance = window.appearance;
196            window.appearance = NSApp.effectiveAppearance;
197            [(MenuDelegate*)delegate runBlockWhenOpen:^() {
198              window.appearance = oldAppearance;
199            }];
200          }
201        }
202      }
203    }
204
205    // Create a synthetic event at the right location and open the menu [option 4].
206    NSPoint locationInWindow = nsCocoaUtils::ConvertPointFromScreen(window, aPosition);
207    NSEvent* event = [NSEvent mouseEventWithType:NSEventTypeRightMouseDown
208                                        location:locationInWindow
209                                   modifierFlags:0
210                                       timestamp:NSProcessInfo.processInfo.systemUptime
211                                    windowNumber:window.windowNumber
212                                         context:nil
213                                     eventNumber:0
214                                      clickCount:1
215                                        pressure:0.0f];
216    [NSMenu popUpContextMenu:aMenu withEvent:event forView:aView];
217  } else {
218    // Open the menu using popUpMenuPositioningItem:atLocation:inView: [option 5].
219    // This is not preferred, because it positions the menu differently from how a native context
220    // menu would be positioned; it enforces aPosition for the top left corner even if this
221    // means that the menu will be displayed in a clipped fashion with scroll arrows.
222    [aMenu popUpMenuPositioningItem:nil atLocation:aPosition inView:nil];
223  }
224}
225
226+ (void)setNeedToUnwindForMenuClosing:(BOOL)aValue {
227  sNeedToUnwindForMenuClosing = aValue;
228}
229
230+ (BOOL)needToUnwindForMenuClosing {
231  return sNeedToUnwindForMenuClosing;
232}
233
234@end
235