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