1/**************************************************************************** 2** 3** Copyright (C) 2018 The Qt Company Ltd. 4** Copyright (C) 2012 Klarälvdalens Datakonsult AB, a KDAB Group company, info@kdab.com, author James Turner <james.turner@kdab.com> 5** Contact: https://www.qt.io/licensing/ 6** 7** This file is part of the plugins of the Qt Toolkit. 8** 9** $QT_BEGIN_LICENSE:LGPL$ 10** Commercial License Usage 11** Licensees holding valid commercial Qt licenses may use this file in 12** accordance with the commercial license agreement provided with the 13** Software or, alternatively, in accordance with the terms contained in 14** a written agreement between you and The Qt Company. For licensing terms 15** and conditions see https://www.qt.io/terms-conditions. For further 16** information use the contact form at https://www.qt.io/contact-us. 17** 18** GNU Lesser General Public License Usage 19** Alternatively, this file may be used under the terms of the GNU Lesser 20** General Public License version 3 as published by the Free Software 21** Foundation and appearing in the file LICENSE.LGPL3 included in the 22** packaging of this file. Please review the following information to 23** ensure the GNU Lesser General Public License version 3 requirements 24** will be met: https://www.gnu.org/licenses/lgpl-3.0.html. 25** 26** GNU General Public License Usage 27** Alternatively, this file may be used under the terms of the GNU 28** General Public License version 2.0 or (at your option) the GNU General 29** Public license version 3 or any later version approved by the KDE Free 30** Qt Foundation. The licenses are as published by the Free Software 31** Foundation and appearing in the file LICENSE.GPL2 and LICENSE.GPL3 32** included in the packaging of this file. Please review the following 33** information to ensure the GNU General Public License requirements will 34** be met: https://www.gnu.org/licenses/gpl-2.0.html and 35** https://www.gnu.org/licenses/gpl-3.0.html. 36** 37** $QT_END_LICENSE$ 38** 39****************************************************************************/ 40 41#include "qcocoamenu.h" 42#include "qcocoansmenu.h" 43 44#include "qcocoahelpers.h" 45 46#include <QtCore/QtDebug> 47#include "qcocoaapplication.h" 48#include "qcocoaintegration.h" 49#include "qcocoamenuloader.h" 50#include "qcocoamenubar.h" 51#include "qcocoawindow.h" 52#include "qcocoascreen.h" 53 54QT_BEGIN_NAMESPACE 55 56QCocoaMenu::QCocoaMenu() : 57 m_attachedItem(nil), 58 m_updateTimer(0), 59 m_enabled(true), 60 m_parentEnabled(true), 61 m_visible(true), 62 m_isOpen(false) 63{ 64 QMacAutoReleasePool pool; 65 66 m_nativeMenu = [[QCocoaNSMenu alloc] initWithPlatformMenu:this]; 67} 68 69QCocoaMenu::~QCocoaMenu() 70{ 71 for (auto *item : qAsConst(m_menuItems)) { 72 if (item->menuParent() == this) 73 item->setMenuParent(nullptr); 74 } 75 76 [m_nativeMenu release]; 77} 78 79void QCocoaMenu::setText(const QString &text) 80{ 81 QMacAutoReleasePool pool; 82 QString stripped = qt_mac_removeAmpersandEscapes(text); 83 m_nativeMenu.title = stripped.toNSString(); 84} 85 86void QCocoaMenu::setMinimumWidth(int width) 87{ 88 m_nativeMenu.minimumWidth = width; 89} 90 91void QCocoaMenu::setFont(const QFont &font) 92{ 93 if (font.resolve()) { 94 NSFont *customMenuFont = [NSFont fontWithName:font.family().toNSString() 95 size:font.pointSize()]; 96 m_nativeMenu.font = customMenuFont; 97 } 98} 99 100NSMenu *QCocoaMenu::nsMenu() const 101{ 102 return static_cast<NSMenu *>(m_nativeMenu); 103} 104 105void QCocoaMenu::insertMenuItem(QPlatformMenuItem *menuItem, QPlatformMenuItem *before) 106{ 107 QMacAutoReleasePool pool; 108 QCocoaMenuItem *cocoaItem = static_cast<QCocoaMenuItem *>(menuItem); 109 QCocoaMenuItem *beforeItem = static_cast<QCocoaMenuItem *>(before); 110 111 cocoaItem->sync(); 112 if (beforeItem) { 113 int index = m_menuItems.indexOf(beforeItem); 114 // if a before item is supplied, it should be in the menu 115 if (index < 0) { 116 qWarning("Before menu item not found"); 117 return; 118 } 119 m_menuItems.insert(index, cocoaItem); 120 } else { 121 m_menuItems.append(cocoaItem); 122 } 123 124 insertNative(cocoaItem, beforeItem); 125 126 // Empty menus on a menubar are hidden by default. If the menu gets 127 // added to the menubar before it contains any item, we need to sync. 128 if (isVisible() && attachedItem().hidden) { 129 if (auto *mb = qobject_cast<QCocoaMenuBar *>(menuParent())) 130 mb->syncMenu(this); 131 } 132} 133 134void QCocoaMenu::insertNative(QCocoaMenuItem *item, QCocoaMenuItem *beforeItem) 135{ 136 item->resolveTargetAction(); 137 NSMenuItem *nativeItem = item->nsItem(); 138 // Someone's adding new items after aboutToShow() was emitted 139 if (isOpen() && nativeItem && item->menu()) 140 item->menu()->setAttachedItem(nativeItem); 141 142 item->setParentEnabled(isEnabled()); 143 144 if (item->isMerged()) 145 return; 146 147 // if the item we're inserting before is merged, skip along until 148 // we find a non-merged real item to insert ahead of. 149 while (beforeItem && beforeItem->isMerged()) { 150 beforeItem = itemOrNull(m_menuItems.indexOf(beforeItem) + 1); 151 } 152 153 if (nativeItem.menu) { 154 qWarning() << "Menu item" << item->text() << "already in menu" << QString::fromNSString(nativeItem.menu.title); 155 return; 156 } 157 158 if (beforeItem) { 159 if (beforeItem->isMerged()) { 160 qWarning("No non-merged before menu item found"); 161 return; 162 } 163 const NSInteger nativeIndex = [m_nativeMenu indexOfItem:beforeItem->nsItem()]; 164 [m_nativeMenu insertItem:nativeItem atIndex:nativeIndex]; 165 } else { 166 [m_nativeMenu addItem:nativeItem]; 167 } 168 item->setMenuParent(this); 169} 170 171bool QCocoaMenu::isOpen() const 172{ 173 return m_isOpen; 174} 175 176void QCocoaMenu::setIsOpen(bool isOpen) 177{ 178 m_isOpen = isOpen; 179} 180 181bool QCocoaMenu::isAboutToShow() const 182{ 183 return m_isAboutToShow; 184} 185 186void QCocoaMenu::setIsAboutToShow(bool isAbout) 187{ 188 m_isAboutToShow = isAbout; 189} 190 191void QCocoaMenu::removeMenuItem(QPlatformMenuItem *menuItem) 192{ 193 QMacAutoReleasePool pool; 194 QCocoaMenuItem *cocoaItem = static_cast<QCocoaMenuItem *>(menuItem); 195 if (!m_menuItems.contains(cocoaItem)) { 196 qWarning("Menu does not contain the item to be removed"); 197 return; 198 } 199 200 if (cocoaItem->menuParent() == this) 201 cocoaItem->setMenuParent(nullptr); 202 203 // Ignore any parent enabled state 204 cocoaItem->setParentEnabled(true); 205 206 m_menuItems.removeOne(cocoaItem); 207 if (!cocoaItem->isMerged()) { 208 if (m_nativeMenu != cocoaItem->nsItem().menu) { 209 qWarning("Item to remove does not belong to this menu"); 210 return; 211 } 212 [m_nativeMenu removeItem:cocoaItem->nsItem()]; 213 } 214} 215 216QCocoaMenuItem *QCocoaMenu::itemOrNull(int index) const 217{ 218 if ((index < 0) || (index >= m_menuItems.size())) 219 return nullptr; 220 221 return m_menuItems.at(index); 222} 223 224void QCocoaMenu::scheduleUpdate() 225{ 226 if (!m_updateTimer) 227 m_updateTimer = startTimer(0); 228} 229 230void QCocoaMenu::timerEvent(QTimerEvent *e) 231{ 232 if (e->timerId() == m_updateTimer) { 233 killTimer(m_updateTimer); 234 m_updateTimer = 0; 235 [m_nativeMenu update]; 236 } 237} 238 239void QCocoaMenu::syncMenuItem(QPlatformMenuItem *menuItem) 240{ 241 syncMenuItem_helper(menuItem, false /*menubarUpdate*/); 242} 243 244void QCocoaMenu::syncMenuItem_helper(QPlatformMenuItem *menuItem, bool menubarUpdate) 245{ 246 QMacAutoReleasePool pool; 247 QCocoaMenuItem *cocoaItem = static_cast<QCocoaMenuItem *>(menuItem); 248 if (!m_menuItems.contains(cocoaItem)) { 249 qWarning("Item does not belong to this menu"); 250 return; 251 } 252 253 const bool wasMerged = cocoaItem->isMerged(); 254 NSMenuItem *oldItem = cocoaItem->nsItem(); 255 NSMenuItem *syncedItem = cocoaItem->sync(); 256 257 if (syncedItem != oldItem) { 258 // native item was changed for some reason 259 if (oldItem) { 260 if (wasMerged) { 261 oldItem.enabled = NO; 262 oldItem.hidden = YES; 263 oldItem.keyEquivalent = @""; 264 oldItem.keyEquivalentModifierMask = NSEventModifierFlagCommand; 265 266 } else { 267 [m_nativeMenu removeItem:oldItem]; 268 } 269 } 270 271 QCocoaMenuItem* beforeItem = itemOrNull(m_menuItems.indexOf(cocoaItem) + 1); 272 insertNative(cocoaItem, beforeItem); 273 } else { 274 // Schedule NSMenuValidation to kick in. This is needed e.g. 275 // when an item's enabled state changes after menuWillOpen: 276 scheduleUpdate(); 277 } 278 279 // This may be a good moment to attach this item's eventual submenu to the 280 // synced item, but only on the condition we're all currently hooked to the 281 // menunbar. A good indicator of this being the right moment is knowing that 282 // we got called from QCocoaMenuBar::updateMenuBarImmediately(). 283 if (menubarUpdate) 284 if (QCocoaMenu *submenu = cocoaItem->menu()) 285 submenu->setAttachedItem(syncedItem); 286} 287 288void QCocoaMenu::syncSeparatorsCollapsible(bool enable) 289{ 290 QMacAutoReleasePool pool; 291 if (enable) { 292 bool previousIsSeparator = true; // setting to true kills all the separators placed at the top. 293 NSMenuItem *previousItem = nil; 294 295 for (NSMenuItem *item in m_nativeMenu.itemArray) { 296 if (item.separatorItem) { 297 if (auto *cocoaItem = qt_objc_cast<QCocoaNSMenuItem *>(item).platformMenuItem) 298 cocoaItem->setVisible(!previousIsSeparator); 299 item.hidden = previousIsSeparator; 300 } 301 302 if (!item.hidden) { 303 previousItem = item; 304 previousIsSeparator = previousItem.separatorItem; 305 } 306 } 307 308 // We now need to check the final item since we don't want any separators at the end of the list. 309 if (previousItem && previousIsSeparator) { 310 if (auto *cocoaItem = qt_objc_cast<QCocoaNSMenuItem *>(previousItem).platformMenuItem) 311 cocoaItem->setVisible(false); 312 previousItem.hidden = YES; 313 } 314 } else { 315 for (auto *item : qAsConst(m_menuItems)) { 316 if (!item->isSeparator()) 317 continue; 318 319 // sync the visiblity directly 320 item->sync(); 321 } 322 } 323} 324 325void QCocoaMenu::setEnabled(bool enabled) 326{ 327 if (m_enabled == enabled) 328 return; 329 m_enabled = enabled; 330 const bool wasParentEnabled = m_parentEnabled; 331 propagateEnabledState(m_enabled); 332 m_parentEnabled = wasParentEnabled; // Reset to the parent value 333} 334 335bool QCocoaMenu::isEnabled() const 336{ 337 return m_enabled && m_parentEnabled; 338} 339 340void QCocoaMenu::setVisible(bool visible) 341{ 342 m_visible = visible; 343} 344 345void QCocoaMenu::showPopup(const QWindow *parentWindow, const QRect &targetRect, const QPlatformMenuItem *item) 346{ 347 QMacAutoReleasePool pool; 348 349 QPoint pos = QPoint(targetRect.left(), targetRect.top() + targetRect.height()); 350 QCocoaWindow *cocoaWindow = parentWindow ? static_cast<QCocoaWindow *>(parentWindow->handle()) : nullptr; 351 NSView *view = cocoaWindow ? cocoaWindow->view() : nil; 352 NSMenuItem *nsItem = item ? ((QCocoaMenuItem *)item)->nsItem() : nil; 353 354 QScreen *screen = nullptr; 355 if (parentWindow) 356 screen = parentWindow->screen(); 357 if (!screen && !QGuiApplication::screens().isEmpty()) 358 screen = QGuiApplication::screens().at(0); 359 Q_ASSERT(screen); 360 361 // Ideally, we would call -popUpMenuPositioningItem:atLocation:inView:. 362 // However, this showed not to work with modal windows where the menu items 363 // would appear disabled. So, we resort to a more artisanal solution. Note 364 // that this implies several things. 365 if (nsItem) { 366 // If we want to position the menu popup so that a specific item lies under 367 // the mouse cursor, we resort to NSPopUpButtonCell to do that. This is the 368 // typical use-case for a choice list, or non-editable combobox. We can't 369 // re-use the popUpContextMenu:withEvent:forView: logic below since it won't 370 // respect the menu's minimum width. 371 NSPopUpButtonCell *popupCell = [[[NSPopUpButtonCell alloc] initTextCell:@"" pullsDown:NO] 372 autorelease]; 373 popupCell.altersStateOfSelectedItem = NO; 374 popupCell.transparent = YES; 375 popupCell.menu = m_nativeMenu; 376 [popupCell selectItem:nsItem]; 377 378 QCocoaScreen *cocoaScreen = static_cast<QCocoaScreen *>(screen->handle()); 379 int availableHeight = cocoaScreen->availableGeometry().height(); 380 const QPoint &globalPos = cocoaWindow->mapToGlobal(pos); 381 int menuHeight = m_nativeMenu.size.height; 382 if (globalPos.y() + menuHeight > availableHeight) { 383 // Maybe we need to fix the vertical popup position but we don't know the 384 // exact popup height at the moment (and Cocoa is just guessing) nor its 385 // position. So, instead of translating by the popup's full height, we need 386 // to estimate where the menu will show up and translate by the remaining height. 387 float idx = ([m_nativeMenu indexOfItem:nsItem] + 1.0f) / m_nativeMenu.numberOfItems; 388 float heightBelowPos = (1.0 - idx) * menuHeight; 389 if (globalPos.y() + heightBelowPos > availableHeight) 390 pos.setY(pos.y() - globalPos.y() + availableHeight - heightBelowPos); 391 } 392 393 NSRect cellFrame = NSMakeRect(pos.x(), pos.y(), m_nativeMenu.minimumWidth, 10); 394 [popupCell performClickWithFrame:cellFrame inView:view]; 395 } else { 396 // Else, we need to transform 'pos' to window or screen coordinates. 397 NSPoint nsPos = NSMakePoint(pos.x() - 1, pos.y()); 398 if (view) { 399 // convert coordinates from view to the view's window 400 nsPos = [view convertPoint:nsPos toView:nil]; 401 } else { 402 nsPos.y = screen->availableVirtualSize().height() - nsPos.y; 403 } 404 405 if (view) { 406 // Finally, we need to synthesize an event. 407 NSEvent *menuEvent = [NSEvent mouseEventWithType:NSEventTypeRightMouseDown 408 location:nsPos 409 modifierFlags:0 410 timestamp:0 411 windowNumber:view ? view.window.windowNumber : 0 412 context:nil 413 eventNumber:0 414 clickCount:1 415 pressure:1.0]; 416 [NSMenu popUpContextMenu:m_nativeMenu withEvent:menuEvent forView:view]; 417 } else { 418 [m_nativeMenu popUpMenuPositioningItem:nsItem atLocation:nsPos inView:nil]; 419 } 420 } 421 422 // The calls above block, and also swallow any mouse release event, 423 // so we need to clear any mouse button that triggered the menu popup. 424 if (!cocoaWindow->isForeignWindow()) 425 [qnsview_cast(view) resetMouseButtons]; 426} 427 428void QCocoaMenu::dismiss() 429{ 430 [m_nativeMenu cancelTracking]; 431} 432 433QPlatformMenuItem *QCocoaMenu::menuItemAt(int position) const 434{ 435 if (0 <= position && position < m_menuItems.count()) 436 return m_menuItems.at(position); 437 438 return nullptr; 439} 440 441QPlatformMenuItem *QCocoaMenu::menuItemForTag(quintptr tag) const 442{ 443 for (auto *item : qAsConst(m_menuItems)) { 444 if (item->tag() == tag) 445 return item; 446 } 447 448 return nullptr; 449} 450 451QList<QCocoaMenuItem *> QCocoaMenu::items() const 452{ 453 return m_menuItems; 454} 455 456QList<QCocoaMenuItem *> QCocoaMenu::merged() const 457{ 458 QList<QCocoaMenuItem *> result; 459 for (auto *item : qAsConst(m_menuItems)) { 460 if (item->menu()) { // recurse into submenus 461 result.append(item->menu()->merged()); 462 continue; 463 } 464 465 if (item->isMerged()) 466 result.append(item); 467 } 468 469 return result; 470} 471 472void QCocoaMenu::propagateEnabledState(bool enabled) 473{ 474 QMacAutoReleasePool pool; // FIXME Is this still needed for Creator? See 6a0bb4206a2928b83648 475 476 m_parentEnabled = enabled; 477 if (!m_enabled && enabled) // Some ancestor was enabled, but this menu is not 478 return; 479 480 for (auto *item : qAsConst(m_menuItems)) { 481 if (QCocoaMenu *menu = item->menu()) 482 menu->propagateEnabledState(enabled); 483 else 484 item->setParentEnabled(enabled); 485 } 486} 487 488void QCocoaMenu::setAttachedItem(NSMenuItem *item) 489{ 490 if (item == m_attachedItem) 491 return; 492 493 if (m_attachedItem) 494 m_attachedItem.submenu = nil; 495 496 m_attachedItem = item; 497 498 if (m_attachedItem) 499 m_attachedItem.submenu = m_nativeMenu; 500 501} 502 503NSMenuItem *QCocoaMenu::attachedItem() const 504{ 505 return m_attachedItem; 506} 507 508QT_END_NAMESPACE 509