summaryrefslogtreecommitdiffstats
path: root/widget/cocoa/MOZMenuOpeningCoordinator.mm
blob: 00c4c8ba011ffdad6ff314f0b3e475d5a2862bf9 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

/*
 * Makes sure that the nested event loop for NSMenu tracking is situated as low
 * on the stack as possible, and that two NSMenu event loops are never nested.
 */

#include "MOZMenuOpeningCoordinator.h"

#include "mozilla/ClearOnShutdown.h"
#include "mozilla/StaticPrefs_widget.h"

#include "nsCocoaFeatures.h"
#include "nsCocoaUtils.h"
#include "nsMenuX.h"
#include "nsObjCExceptions.h"
#include "SDKDeclarations.h"

static BOOL sNeedToUnwindForMenuClosing = NO;

@interface MOZMenuOpeningInfo : NSObject
@property NSInteger handle;
@property(retain) NSMenu* menu;
@property NSPoint position;
@property(retain) NSView* view;
@property(retain) NSAppearance* appearance;
@end

@implementation MOZMenuOpeningInfo
@end

@implementation MOZMenuOpeningCoordinator {
  // non-nil between asynchronouslyOpenMenu:atScreenPosition:forView: and the
  // time at at which it is unqueued in _runMenu.
  MOZMenuOpeningInfo* mPendingOpening;  // strong

  // An incrementing counter
  NSInteger mLastHandle;

  // YES while _runMenu is on the stack
  BOOL mRunMenuIsOnTheStack;
}

+ (instancetype)sharedInstance {
  static MOZMenuOpeningCoordinator* sInstance = nil;
  if (!sInstance) {
    sInstance = [[MOZMenuOpeningCoordinator alloc] init];
    mozilla::RunOnShutdown([&]() {
      [sInstance release];
      sInstance = nil;
    });
  }
  return sInstance;
}

- (void)dealloc {
  MOZ_RELEASE_ASSERT(!mPendingOpening, "should be empty at shutdown");
  [super dealloc];
}

- (NSInteger)asynchronouslyOpenMenu:(NSMenu*)aMenu
                   atScreenPosition:(NSPoint)aPosition
                            forView:(NSView*)aView
                     withAppearance:(NSAppearance*)aAppearance {
  MOZ_RELEASE_ASSERT(!mPendingOpening,
                     "A menu is already waiting to open. Before opening the next one, either wait "
                     "for this one to open or cancel the request.");

  NSInteger handle = ++mLastHandle;

  MOZMenuOpeningInfo* info = [[MOZMenuOpeningInfo alloc] init];
  info.handle = handle;
  info.menu = aMenu;
  info.position = aPosition;
  info.view = aView;
  info.appearance = aAppearance;
  mPendingOpening = [info retain];
  [info release];

  if (!mRunMenuIsOnTheStack) {
    // Call _runMenu from the event loop, so that it doesn't block this call.
    [self performSelector:@selector(_runMenu) withObject:nil afterDelay:0.0];
  }

  return handle;
}

- (void)_runMenu {
  MOZ_RELEASE_ASSERT(!mRunMenuIsOnTheStack);

  mRunMenuIsOnTheStack = YES;

  while (mPendingOpening) {
    MOZMenuOpeningInfo* info = [mPendingOpening retain];
    [mPendingOpening release];
    mPendingOpening = nil;

    @try {
      [self _openMenu:info.menu
          atScreenPosition:info.position
                   forView:info.view
            withAppearance:info.appearance];
    } @catch (NSException* exception) {
      nsObjCExceptionLog(exception);
    }

    [info release];

    // We have exited _openMenu's nested event loop.
    MOZMenuOpeningCoordinator.needToUnwindForMenuClosing = NO;
  }

  mRunMenuIsOnTheStack = NO;
}

- (void)cancelAsynchronousOpening:(NSInteger)aHandle {
  if (mPendingOpening && mPendingOpening.handle == aHandle) {
    [mPendingOpening release];
    mPendingOpening = nil;
  }
}

- (void)_openMenu:(NSMenu*)aMenu
    atScreenPosition:(NSPoint)aPosition
             forView:(NSView*)aView
      withAppearance:(NSAppearance*)aAppearance {
  // There are multiple ways to display an NSMenu as a context menu.
  //
  //  1. We can return the NSMenu from -[ChildView menuForEvent:] and the NSView will open it for
  //     us.
  //  2. We can call +[NSMenu popUpContextMenu:withEvent:forView:] inside a mouseDown handler with a
  //     real mouse down event.
  //  3. We can call +[NSMenu popUpContextMenu:withEvent:forView:] at a later time, with a real
  //     mouse event that we stored earlier.
  //  4. We can call +[NSMenu popUpContextMenu:withEvent:forView:] at any time, with a synthetic
  //     mouse event that we create just for that purpose.
  //  5. We can call -[NSMenu popUpMenuPositioningItem:atLocation:inView:] and it just takes a
  //     position, not an event.
  //
  // 1-4 look the same, 5 looks different: 5 is made for use with NSPopUpButton, where the selected
  // item needs to be shown at a specific position. If a tall menu is opened with a position close
  // to the bottom edge of the screen, 5 results in a cropped menu with scroll arrows, even if the
  // entire menu would fit on the screen, due to the positioning constraint.
  // 1-2 only work if the menu contents are known synchronously during the call to menuForEvent or
  // during the mouseDown event handler.
  // NativeMenuMac::ShowAsContextMenu can be called at any time. It could be called during a
  // menuForEvent call (during a "contextmenu" event handler), or during a mouseDown handler, or at
  // a later time.
  // The code below uses option 4 as the preferred option because it's the simplest: It works in all
  // scenarios and it doesn't have the positioning drawbacks of option 5.

  if (aAppearance) {
#if !defined(MAC_OS_VERSION_11_0) || MAC_OS_X_VERSION_MAX_ALLOWED < MAC_OS_VERSION_11_0
    if (nsCocoaFeatures::OnBigSurOrLater()) {
#else
    if (@available(macOS 11.0, *)) {
#endif
      // By default, NSMenu inherits its appearance from the opening NSEvent's
      // window. If CSS has overridden it, on Big Sur + we can respect it with
      // -[NSMenu setAppearance].
      aMenu.appearance = aAppearance;
    }
  }

  if (aView) {
    NSWindow* window = aView.window;
    // Create a synthetic event at the right location and open the menu [option 4].
    NSPoint locationInWindow = nsCocoaUtils::ConvertPointFromScreen(window, aPosition);
    NSEvent* event = [NSEvent mouseEventWithType:NSEventTypeRightMouseDown
                                        location:locationInWindow
                                   modifierFlags:0
                                       timestamp:NSProcessInfo.processInfo.systemUptime
                                    windowNumber:window.windowNumber
                                         context:nil
                                     eventNumber:0
                                      clickCount:1
                                        pressure:0.0f];
    [NSMenu popUpContextMenu:aMenu withEvent:event forView:aView];
  } else {
    // Open the menu using popUpMenuPositioningItem:atLocation:inView: [option 5].
    // This is not preferred, because it positions the menu differently from how a native context
    // menu would be positioned; it enforces aPosition for the top left corner even if this
    // means that the menu will be displayed in a clipped fashion with scroll arrows.
    [aMenu popUpMenuPositioningItem:nil atLocation:aPosition inView:nil];
  }
}

+ (void)setNeedToUnwindForMenuClosing:(BOOL)aValue {
  sNeedToUnwindForMenuClosing = aValue;
}

+ (BOOL)needToUnwindForMenuClosing {
  return sNeedToUnwindForMenuClosing;
}

@end