// Copyright (C) 2018 The Qt Company Ltd. // Copyright (C) 2012 Klarälvdalens Datakonsult AB, a KDAB Group company, info@kdab.com, author James Turner // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only #include #include "qcocoamenu.h" #include "qcocoansmenu.h" #include "qcocoahelpers.h" #include #include "qcocoaapplication.h" #include "qcocoaintegration.h" #include "qcocoamenuloader.h" #include "qcocoamenubar.h" #include "qcocoawindow.h" #include "qcocoascreen.h" #include "qcocoaapplicationdelegate.h" #include #include QT_BEGIN_NAMESPACE QCocoaMenu::QCocoaMenu() : m_attachedItem(nil), m_updateTimer(0), m_enabled(true), m_parentEnabled(true), m_visible(true), m_isOpen(false) { QMacAutoReleasePool pool; m_nativeMenu = [[QCocoaNSMenu alloc] initWithPlatformMenu:this]; } QCocoaMenu::~QCocoaMenu() { for (auto *item : std::as_const(m_menuItems)) { if (item->menuParent() == this) item->setMenuParent(nullptr); } if (isOpen()) dismiss(); [m_nativeMenu release]; } void QCocoaMenu::setText(const QString &text) { QMacAutoReleasePool pool; QString stripped = qt_mac_removeAmpersandEscapes(text); m_nativeMenu.title = stripped.toNSString(); } void QCocoaMenu::setMinimumWidth(int width) { m_nativeMenu.minimumWidth = width; } void QCocoaMenu::setFont(const QFont &font) { if (font.resolveMask()) { NSFont *customMenuFont = [NSFont fontWithName:font.families().constFirst().toNSString() size:font.pointSize()]; m_nativeMenu.font = customMenuFont; } } NSMenu *QCocoaMenu::nsMenu() const { return static_cast(m_nativeMenu); } void QCocoaMenu::setAsDockMenu() const { QMacAutoReleasePool pool; QCocoaApplicationDelegate.sharedDelegate.dockMenu = m_nativeMenu; } void QCocoaMenu::insertMenuItem(QPlatformMenuItem *menuItem, QPlatformMenuItem *before) { QMacAutoReleasePool pool; QCocoaMenuItem *cocoaItem = static_cast(menuItem); QCocoaMenuItem *beforeItem = static_cast(before); cocoaItem->sync(); if (beforeItem) { int index = m_menuItems.indexOf(beforeItem); // if a before item is supplied, it should be in the menu if (index < 0) { qCWarning(lcQpaMenus) << beforeItem << "not in" << m_menuItems; return; } m_menuItems.insert(index, cocoaItem); } else { m_menuItems.append(cocoaItem); } insertNative(cocoaItem, beforeItem); // Empty menus on a menubar are hidden by default. If the menu gets // added to the menubar before it contains any item, we need to sync. if (isVisible() && attachedItem().hidden) { if (auto *mb = qobject_cast(menuParent())) mb->syncMenu(this); } } void QCocoaMenu::insertNative(QCocoaMenuItem *item, QCocoaMenuItem *beforeItem) { item->resolveTargetAction(); NSMenuItem *nativeItem = item->nsItem(); // Someone's adding new items after aboutToShow() was emitted if (isOpen() && nativeItem && item->menu()) item->menu()->setAttachedItem(nativeItem); item->setParentEnabled(isEnabled()); if (item->isMerged()) return; // if the item we're inserting before is merged, skip along until // we find a non-merged real item to insert ahead of. while (beforeItem && beforeItem->isMerged()) { beforeItem = itemOrNull(m_menuItems.indexOf(beforeItem) + 1); } if (nativeItem.menu) { qCWarning(lcQpaMenus) << "Menu item" << item->text() << "already in menu" << QString::fromNSString(nativeItem.menu.title); return; } if (beforeItem) { if (beforeItem->isMerged()) { qCWarning(lcQpaMenus, "No non-merged before menu item found"); return; } const NSInteger nativeIndex = [m_nativeMenu indexOfItem:beforeItem->nsItem()]; [m_nativeMenu insertItem:nativeItem atIndex:nativeIndex]; } else { [m_nativeMenu addItem:nativeItem]; } item->setMenuParent(this); } bool QCocoaMenu::isOpen() const { return m_isOpen; } void QCocoaMenu::setIsOpen(bool isOpen) { m_isOpen = isOpen; } bool QCocoaMenu::isAboutToShow() const { return m_isAboutToShow; } void QCocoaMenu::setIsAboutToShow(bool isAbout) { m_isAboutToShow = isAbout; } void QCocoaMenu::removeMenuItem(QPlatformMenuItem *menuItem) { QMacAutoReleasePool pool; QCocoaMenuItem *cocoaItem = static_cast(menuItem); if (!m_menuItems.contains(cocoaItem)) { qCWarning(lcQpaMenus) << m_menuItems << "does not contain" << cocoaItem; return; } if (cocoaItem->menuParent() == this) cocoaItem->setMenuParent(nullptr); // Ignore any parent enabled state cocoaItem->setParentEnabled(true); m_menuItems.removeOne(cocoaItem); if (!cocoaItem->isMerged()) { if (m_nativeMenu != cocoaItem->nsItem().menu) { qCWarning(lcQpaMenus) << cocoaItem << "does not belong to" << m_nativeMenu; return; } [m_nativeMenu removeItem:cocoaItem->nsItem()]; } } QCocoaMenuItem *QCocoaMenu::itemOrNull(int index) const { if ((index < 0) || (index >= m_menuItems.size())) return nullptr; return m_menuItems.at(index); } void QCocoaMenu::scheduleUpdate() { if (!m_updateTimer) m_updateTimer = startTimer(0); } void QCocoaMenu::timerEvent(QTimerEvent *e) { if (e->timerId() == m_updateTimer) { killTimer(m_updateTimer); m_updateTimer = 0; [m_nativeMenu update]; } } void QCocoaMenu::syncMenuItem(QPlatformMenuItem *menuItem) { syncMenuItem_helper(menuItem, false /*menubarUpdate*/); } void QCocoaMenu::syncMenuItem_helper(QPlatformMenuItem *menuItem, bool menubarUpdate) { QMacAutoReleasePool pool; QCocoaMenuItem *cocoaItem = static_cast(menuItem); if (!m_menuItems.contains(cocoaItem)) { qCWarning(lcQpaMenus) << cocoaItem << "does not belong to" << this; return; } const bool wasMerged = cocoaItem->isMerged(); NSMenuItem *oldItem = cocoaItem->nsItem(); NSMenuItem *syncedItem = cocoaItem->sync(); if (syncedItem != oldItem) { // native item was changed for some reason if (oldItem) { if (wasMerged) { oldItem.enabled = NO; oldItem.hidden = YES; oldItem.keyEquivalent = @""; oldItem.keyEquivalentModifierMask = NSEventModifierFlagCommand; } else { [m_nativeMenu removeItem:oldItem]; } } QCocoaMenuItem* beforeItem = itemOrNull(m_menuItems.indexOf(cocoaItem) + 1); insertNative(cocoaItem, beforeItem); } else { // Schedule NSMenuValidation to kick in. This is needed e.g. // when an item's enabled state changes after menuWillOpen: scheduleUpdate(); } // This may be a good moment to attach this item's eventual submenu to the // synced item, but only on the condition we're all currently hooked to the // menunbar. A good indicator of this being the right moment is knowing that // we got called from QCocoaMenuBar::updateMenuBarImmediately(). if (menubarUpdate) if (QCocoaMenu *submenu = cocoaItem->menu()) submenu->setAttachedItem(syncedItem); } void QCocoaMenu::syncSeparatorsCollapsible(bool enable) { QMacAutoReleasePool pool; if (enable) { bool previousIsSeparator = true; // setting to true kills all the separators placed at the top. NSMenuItem *lastVisibleItem = nil; for (NSMenuItem *item in m_nativeMenu.itemArray) { if (item.separatorItem) { // hide item if previous was a separator, or if it's explicitly hidden bool hideItem = previousIsSeparator; if (auto *cocoaItem = qt_objc_cast(item).platformMenuItem) hideItem = previousIsSeparator || !cocoaItem->isVisible(); item.hidden = hideItem; } if (!item.hidden) { lastVisibleItem = item; previousIsSeparator = lastVisibleItem.separatorItem; } } // We now need to check the final item since we don't want any separators at the end of the list. if (lastVisibleItem && lastVisibleItem.separatorItem) lastVisibleItem.hidden = YES; } else { for (auto *item : std::as_const(m_menuItems)) { if (!item->isSeparator()) continue; // sync the visibility directly item->sync(); } } } void QCocoaMenu::setEnabled(bool enabled) { if (m_enabled == enabled) return; m_enabled = enabled; const bool wasParentEnabled = m_parentEnabled; propagateEnabledState(m_enabled); m_parentEnabled = wasParentEnabled; // Reset to the parent value } bool QCocoaMenu::isEnabled() const { return m_enabled && m_parentEnabled; } void QCocoaMenu::setVisible(bool visible) { m_visible = visible; } void QCocoaMenu::showPopup(const QWindow *parentWindow, const QRect &targetRect, const QPlatformMenuItem *item) { QMacAutoReleasePool pool; QPointer guard = this; QPoint pos = QPoint(targetRect.left(), targetRect.top() + targetRect.height()); // If the app quits while the menu is open (e.g. through a timer that starts before the menu was opened), // then the window will have been destroyed before this function finishes executing. Account for that with QPointer. QPointer cocoaWindow = parentWindow ? static_cast(parentWindow->handle()) : nullptr; NSView *view = cocoaWindow ? cocoaWindow->view() : nil; NSMenuItem *nsItem = item ? ((QCocoaMenuItem *)item)->nsItem() : nil; // store the window that this popup belongs to so that we can evaluate whether we are modally blocked bool resetMenuParent = false; if (!menuParent()) { setMenuParent(cocoaWindow); resetMenuParent = true; } auto menuParentGuard = qScopeGuard([&]{ if (resetMenuParent) setMenuParent(nullptr); }); QScreen *screen = nullptr; if (parentWindow) screen = parentWindow->screen(); if (!screen && !QGuiApplication::screens().isEmpty()) screen = QGuiApplication::screens().at(0); Q_ASSERT(screen); // Ideally, we would call -popUpMenuPositioningItem:atLocation:inView:. // However, this showed not to work with modal windows where the menu items // would appear disabled. So, we resort to a more artisanal solution. Note // that this implies several things. if (nsItem) { // If we want to position the menu popup so that a specific item lies under // the mouse cursor, we resort to NSPopUpButtonCell to do that. This is the // typical use-case for a choice list, or non-editable combobox. We can't // re-use the popUpContextMenu:withEvent:forView: logic below since it won't // respect the menu's minimum width. NSPopUpButtonCell *popupCell = [[[NSPopUpButtonCell alloc] initTextCell:@"" pullsDown:NO] autorelease]; popupCell.altersStateOfSelectedItem = NO; popupCell.transparent = YES; popupCell.menu = m_nativeMenu; [popupCell selectItem:nsItem]; QCocoaScreen *cocoaScreen = static_cast(screen->handle()); int availableHeight = cocoaScreen->availableGeometry().height(); const QPoint globalPos = cocoaWindow ? cocoaWindow->mapToGlobal(pos) : pos; int menuHeight = m_nativeMenu.size.height; if (globalPos.y() + menuHeight > availableHeight) { // Maybe we need to fix the vertical popup position but we don't know the // exact popup height at the moment (and Cocoa is just guessing) nor its // position. So, instead of translating by the popup's full height, we need // to estimate where the menu will show up and translate by the remaining height. float idx = ([m_nativeMenu indexOfItem:nsItem] + 1.0f) / m_nativeMenu.numberOfItems; float heightBelowPos = (1.0 - idx) * menuHeight; if (globalPos.y() + heightBelowPos > availableHeight) pos.setY(pos.y() - globalPos.y() + availableHeight - heightBelowPos); } NSRect cellFrame = NSMakeRect(pos.x(), pos.y(), m_nativeMenu.minimumWidth, 10); [popupCell performClickWithFrame:cellFrame inView:view]; } else { // Else, we need to transform 'pos' to window or screen coordinates. NSPoint nsPos = NSMakePoint(pos.x() - 1, pos.y()); if (view) { // convert coordinates from view to the view's window nsPos = [view convertPoint:nsPos toView:nil]; } else { nsPos.y = screen->availableVirtualSize().height() - nsPos.y; } if (view) { // Finally, we need to synthesize an event. NSEvent *menuEvent = [NSEvent mouseEventWithType:NSEventTypeRightMouseDown location:nsPos modifierFlags:0 timestamp:0 windowNumber:view ? view.window.windowNumber : 0 context:nil eventNumber:0 clickCount:1 pressure:1.0]; [NSMenu popUpContextMenu:m_nativeMenu withEvent:menuEvent forView:view]; } else { [m_nativeMenu popUpMenuPositioningItem:nsItem atLocation:nsPos inView:nil]; } } if (!guard) { menuParentGuard.dismiss(); return; } // The calls above block, and also swallow any mouse release event, // so we need to clear any mouse button that triggered the menu popup. if (cocoaWindow && !cocoaWindow->isForeignWindow()) [qnsview_cast(view) resetMouseButtons]; } void QCocoaMenu::dismiss() { [m_nativeMenu cancelTracking]; } QPlatformMenuItem *QCocoaMenu::menuItemAt(int position) const { if (0 <= position && position < m_menuItems.count()) return m_menuItems.at(position); return nullptr; } QPlatformMenuItem *QCocoaMenu::menuItemForTag(quintptr tag) const { for (auto *item : std::as_const(m_menuItems)) { if (item->tag() == tag) return item; } return nullptr; } QList QCocoaMenu::items() const { return m_menuItems; } QList QCocoaMenu::merged() const { QList result; for (auto *item : std::as_const(m_menuItems)) { if (item->menu()) { // recurse into submenus result.append(item->menu()->merged()); continue; } if (item->isMerged()) result.append(item); } return result; } void QCocoaMenu::propagateEnabledState(bool enabled) { QMacAutoReleasePool pool; // FIXME Is this still needed for Creator? See 6a0bb4206a2928b83648 m_parentEnabled = enabled; if (!m_enabled && enabled) // Some ancestor was enabled, but this menu is not return; for (auto *item : std::as_const(m_menuItems)) { if (QCocoaMenu *menu = item->menu()) menu->propagateEnabledState(enabled); else item->setParentEnabled(enabled); } } void QCocoaMenu::setAttachedItem(NSMenuItem *item) { if (item == m_attachedItem) return; if (m_attachedItem) m_attachedItem.submenu = nil; m_attachedItem = item; if (m_attachedItem) m_attachedItem.submenu = m_nativeMenu; // NSMenuItems with a submenu and submenuAction: as the item's action // will not take part in NSMenuValidation, so explicitly enable/disable // the item here. See also QCocoaMenuItem::resolveTargetAction() m_attachedItem.enabled = m_attachedItem.hasSubmenu; } NSMenuItem *QCocoaMenu::attachedItem() const { return m_attachedItem; } QT_END_NAMESPACE