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