/**************************************************************************** ** ** Copyright (C) 2018 The Qt Company Ltd. ** Copyright (C) 2012 Klarälvdalens Datakonsult AB, a KDAB Group company, info@kdab.com, author James Turner ** Contact: https://www.qt.io/licensing/ ** ** This file is part of the plugins of the Qt Toolkit. ** ** $QT_BEGIN_LICENSE:LGPL$ ** Commercial License Usage ** Licensees holding valid commercial Qt licenses may use this file in ** accordance with the commercial license agreement provided with the ** Software or, alternatively, in accordance with the terms contained in ** a written agreement between you and The Qt Company. For licensing terms ** and conditions see https://www.qt.io/terms-conditions. For further ** information use the contact form at https://www.qt.io/contact-us. ** ** GNU Lesser General Public License Usage ** Alternatively, this file may be used under the terms of the GNU Lesser ** General Public License version 3 as published by the Free Software ** Foundation and appearing in the file LICENSE.LGPL3 included in the ** packaging of this file. Please review the following information to ** ensure the GNU Lesser General Public License version 3 requirements ** will be met: https://www.gnu.org/licenses/lgpl-3.0.html. ** ** GNU General Public License Usage ** Alternatively, this file may be used under the terms of the GNU ** General Public License version 2.0 or (at your option) the GNU General ** Public license version 3 or any later version approved by the KDE Free ** Qt Foundation. The licenses are as published by the Free Software ** Foundation and appearing in the file LICENSE.GPL2 and LICENSE.GPL3 ** included in the packaging of this file. Please review the following ** information to ensure the GNU General Public License requirements will ** be met: https://www.gnu.org/licenses/gpl-2.0.html and ** https://www.gnu.org/licenses/gpl-3.0.html. ** ** $QT_END_LICENSE$ ** ****************************************************************************/ #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" 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 : qAsConst(m_menuItems)) { if (item->menuParent() == this) item->setMenuParent(nullptr); } [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.resolve()) { NSFont *customMenuFont = [NSFont fontWithName:font.family().toNSString() size:font.pointSize()]; m_nativeMenu.font = customMenuFont; } } NSMenu *QCocoaMenu::nsMenu() const { return static_cast(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) { qWarning("Before menu item not found"); 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) { qWarning() << "Menu item" << item->text() << "already in menu" << QString::fromNSString(nativeItem.menu.title); return; } if (beforeItem) { if (beforeItem->isMerged()) { qWarning("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)) { qWarning("Menu does not contain the item to be removed"); 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) { qWarning("Item to remove does not belong to this menu"); 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)) { qWarning("Item does not belong to this menu"); 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 *previousItem = nil; for (NSMenuItem *item in m_nativeMenu.itemArray) { if (item.separatorItem) { if (auto *cocoaItem = qt_objc_cast(item).platformMenuItem) cocoaItem->setVisible(!previousIsSeparator); item.hidden = previousIsSeparator; } if (!item.hidden) { previousItem = item; previousIsSeparator = previousItem.separatorItem; } } // We now need to check the final item since we don't want any separators at the end of the list. if (previousItem && previousIsSeparator) { if (auto *cocoaItem = qt_objc_cast(previousItem).platformMenuItem) cocoaItem->setVisible(false); previousItem.hidden = YES; } } else { for (auto *item : qAsConst(m_menuItems)) { if (!item->isSeparator()) continue; // sync the visiblity 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_attachedItem ? m_attachedItem.enabled : 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; QPoint pos = QPoint(targetRect.left(), targetRect.top() + targetRect.height()); QCocoaWindow *cocoaWindow = parentWindow ? static_cast(parentWindow->handle()) : nullptr; NSView *view = cocoaWindow ? cocoaWindow->view() : nil; NSMenuItem *nsItem = item ? ((QCocoaMenuItem *)item)->nsItem() : nil; 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->mapToGlobal(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]; } } // 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->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 : qAsConst(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 : qAsConst(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 : qAsConst(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; } NSMenuItem *QCocoaMenu::attachedItem() const { return m_attachedItem; } QT_END_NAMESPACE