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 <qpa/qplatformtheme.h>
42
43#include "qcocoamenuitem.h"
44
45#include "qcocoansmenu.h"
46#include "qcocoamenu.h"
47#include "qcocoamenubar.h"
48#include "qcocoahelpers.h"
49#include "qcocoaapplication.h" // for custom application category
50#include "qcocoamenuloader.h"
51#include <QtGui/private/qcoregraphics_p.h>
52#include <QtCore/qregularexpression.h>
53
54#include <QtCore/QDebug>
55#include <QtCore/QRegExp>
56
57QT_BEGIN_NAMESPACE
58
59static const char *application_menu_strings[] =
60{
61    QT_TRANSLATE_NOOP("MAC_APPLICATION_MENU","About %1"),
62    QT_TRANSLATE_NOOP("MAC_APPLICATION_MENU","Preferences..."),
63    QT_TRANSLATE_NOOP("MAC_APPLICATION_MENU","Services"),
64    QT_TRANSLATE_NOOP("MAC_APPLICATION_MENU","Hide %1"),
65    QT_TRANSLATE_NOOP("MAC_APPLICATION_MENU","Hide Others"),
66    QT_TRANSLATE_NOOP("MAC_APPLICATION_MENU","Show All"),
67    QT_TRANSLATE_NOOP("MAC_APPLICATION_MENU","Quit %1")
68};
69
70QString qt_mac_applicationmenu_string(int type)
71{
72    QString menuString = QString::fromLatin1(application_menu_strings[type]);
73    const QString translated = QCoreApplication::translate("QMenuBar", application_menu_strings[type]);
74    if (translated != menuString) {
75        return translated;
76    } else {
77        return QCoreApplication::translate("MAC_APPLICATION_MENU", application_menu_strings[type]);
78    }
79}
80
81static quint32 constructModifierMask(quint32 accel_key)
82{
83    quint32 ret = 0;
84    const bool dontSwap = qApp->testAttribute(Qt::AA_MacDontSwapCtrlAndMeta);
85    if ((accel_key & Qt::CTRL) == Qt::CTRL)
86        ret |= (dontSwap ? NSEventModifierFlagControl : NSEventModifierFlagCommand);
87    if ((accel_key & Qt::META) == Qt::META)
88        ret |= (dontSwap ? NSEventModifierFlagCommand : NSEventModifierFlagControl);
89    if ((accel_key & Qt::ALT) == Qt::ALT)
90        ret |= NSEventModifierFlagOption;
91    if ((accel_key & Qt::SHIFT) == Qt::SHIFT)
92        ret |= NSEventModifierFlagShift;
93    return ret;
94}
95
96#ifndef QT_NO_SHORTCUT
97// return an autoreleased string given a QKeySequence (currently only looks at the first one).
98NSString *keySequenceToKeyEqivalent(const QKeySequence &accel)
99{
100    quint32 accel_key = (accel[0] & ~(Qt::MODIFIER_MASK | Qt::UNICODE_ACCEL));
101    QChar cocoa_key = qt_mac_qtKey2CocoaKey(Qt::Key(accel_key));
102    if (cocoa_key.isNull())
103        cocoa_key = QChar(accel_key).toLower().unicode();
104    // Similar to qt_mac_removePrivateUnicode change the delete key so the symbol is correctly seen in native menubar
105    if (cocoa_key.unicode() == NSDeleteFunctionKey)
106        cocoa_key = NSDeleteCharacter;
107    return [NSString stringWithCharacters:&cocoa_key.unicode() length:1];
108}
109
110// return the cocoa modifier mask for the QKeySequence (currently only looks at the first one).
111NSUInteger keySequenceModifierMask(const QKeySequence &accel)
112{
113    return constructModifierMask(accel[0]);
114}
115#endif
116
117QCocoaMenuItem::QCocoaMenuItem() :
118    m_native(nil),
119    m_itemView(nil),
120    m_menu(nullptr),
121    m_role(NoRole),
122    m_iconSize(16),
123    m_textSynced(false),
124    m_isVisible(true),
125    m_enabled(true),
126    m_parentEnabled(true),
127    m_isSeparator(false),
128    m_checked(false),
129    m_merged(false)
130{
131}
132
133QCocoaMenuItem::~QCocoaMenuItem()
134{
135    QMacAutoReleasePool pool;
136
137    if (m_menu && m_menu->menuParent() == this)
138        m_menu->setMenuParent(nullptr);
139    if (m_merged) {
140        m_native.hidden = YES;
141    } else {
142        if (m_menu && m_menu->attachedItem() == m_native)
143            m_menu->setAttachedItem(nil);
144        [m_native release];
145    }
146
147    [m_itemView release];
148}
149
150void QCocoaMenuItem::setText(const QString &text)
151{
152    m_text = text;
153}
154
155void QCocoaMenuItem::setIcon(const QIcon &icon)
156{
157    m_icon = icon;
158}
159
160void QCocoaMenuItem::setMenu(QPlatformMenu *menu)
161{
162    if (menu == m_menu)
163        return;
164
165    bool setAttached = false;
166    if ([m_native.menu isKindOfClass:[QCocoaNSMenu class]]) {
167        auto parentMenu = static_cast<QCocoaNSMenu *>(m_native.menu);
168        setAttached = parentMenu.platformMenu && parentMenu.platformMenu->isAboutToShow();
169    }
170
171    if (m_menu && m_menu->menuParent() == this) {
172        m_menu->setMenuParent(nullptr);
173        // Free the menu from its parent's influence
174        m_menu->propagateEnabledState(true);
175        if (m_native && m_menu->attachedItem() == m_native)
176            m_menu->setAttachedItem(nil);
177    }
178
179    QMacAutoReleasePool pool;
180    m_menu = static_cast<QCocoaMenu *>(menu);
181    if (m_menu) {
182        m_menu->setMenuParent(this);
183        m_menu->propagateEnabledState(isEnabled());
184        if (setAttached)
185            m_menu->setAttachedItem(m_native);
186    } else {
187        // we previously had a menu, but no longer
188        // clear out our item so the nexy sync() call builds a new one
189        [m_native release];
190        m_native = nil;
191    }
192}
193
194void QCocoaMenuItem::setVisible(bool isVisible)
195{
196    m_isVisible = isVisible;
197}
198
199void QCocoaMenuItem::setIsSeparator(bool isSeparator)
200{
201    m_isSeparator = isSeparator;
202}
203
204void QCocoaMenuItem::setFont(const QFont &font)
205{
206    Q_UNUSED(font)
207}
208
209void QCocoaMenuItem::setRole(MenuRole role)
210{
211    if (role != m_role)
212        m_textSynced = false; // Changing role deserves a second chance.
213    m_role = role;
214}
215
216#ifndef QT_NO_SHORTCUT
217void QCocoaMenuItem::setShortcut(const QKeySequence& shortcut)
218{
219    m_shortcut = shortcut;
220}
221#endif
222
223void QCocoaMenuItem::setChecked(bool isChecked)
224{
225    m_checked = isChecked;
226}
227
228void QCocoaMenuItem::setEnabled(bool enabled)
229{
230    if (m_enabled != enabled) {
231        m_enabled = enabled;
232        if (m_menu)
233            m_menu->propagateEnabledState(isEnabled());
234    }
235}
236
237void QCocoaMenuItem::setNativeContents(WId item)
238{
239    NSView *itemView = (NSView *)item;
240    if (m_itemView == itemView)
241        return;
242    [m_itemView release];
243    m_itemView = [itemView retain];
244    m_itemView.autoresizesSubviews = YES;
245    m_itemView.autoresizingMask = NSViewWidthSizable;
246    m_itemView.hidden = NO;
247    m_itemView.needsDisplay = YES;
248}
249
250static QPlatformMenuItem::MenuRole detectMenuRole(const QString &caption)
251{
252    QString captionNoAmpersand(caption);
253    captionNoAmpersand.remove(QLatin1Char('&'));
254    const QString aboutString = QCoreApplication::translate("QCocoaMenuItem", "About");
255    if (captionNoAmpersand.startsWith(aboutString, Qt::CaseInsensitive)
256        || captionNoAmpersand.endsWith(aboutString, Qt::CaseInsensitive)) {
257        static const QRegularExpression qtRegExp(QLatin1String("qt$"), QRegularExpression::CaseInsensitiveOption);
258        if (captionNoAmpersand.contains(qtRegExp))
259            return QPlatformMenuItem::AboutQtRole;
260        return QPlatformMenuItem::AboutRole;
261    }
262    if (captionNoAmpersand.startsWith(QCoreApplication::translate("QCocoaMenuItem", "Config"), Qt::CaseInsensitive)
263        || captionNoAmpersand.startsWith(QCoreApplication::translate("QCocoaMenuItem", "Preference"), Qt::CaseInsensitive)
264        || captionNoAmpersand.startsWith(QCoreApplication::translate("QCocoaMenuItem", "Options"), Qt::CaseInsensitive)
265        || captionNoAmpersand.startsWith(QCoreApplication::translate("QCocoaMenuItem", "Setting"), Qt::CaseInsensitive)
266        || captionNoAmpersand.startsWith(QCoreApplication::translate("QCocoaMenuItem", "Setup"), Qt::CaseInsensitive)) {
267        return QPlatformMenuItem::PreferencesRole;
268    }
269    if (captionNoAmpersand.startsWith(QCoreApplication::translate("QCocoaMenuItem", "Quit"), Qt::CaseInsensitive)
270        || captionNoAmpersand.startsWith(QCoreApplication::translate("QCocoaMenuItem", "Exit"), Qt::CaseInsensitive)) {
271        return QPlatformMenuItem::QuitRole;
272    }
273    if (!captionNoAmpersand.compare(QCoreApplication::translate("QCocoaMenuItem", "Cut"), Qt::CaseInsensitive))
274        return QPlatformMenuItem::CutRole;
275    if (!captionNoAmpersand.compare(QCoreApplication::translate("QCocoaMenuItem", "Copy"), Qt::CaseInsensitive))
276        return QPlatformMenuItem::CopyRole;
277    if (!captionNoAmpersand.compare(QCoreApplication::translate("QCocoaMenuItem", "Paste"), Qt::CaseInsensitive))
278        return QPlatformMenuItem::PasteRole;
279    if (!captionNoAmpersand.compare(QCoreApplication::translate("QCocoaMenuItem", "Select All"), Qt::CaseInsensitive))
280        return QPlatformMenuItem::SelectAllRole;
281    return QPlatformMenuItem::NoRole;
282}
283
284NSMenuItem *QCocoaMenuItem::sync()
285{
286    if (m_isSeparator != m_native.separatorItem) {
287        [m_native release];
288        if (m_isSeparator)
289            m_native = [[QCocoaNSMenuItem separatorItemWithPlatformMenuItem:this] retain];
290        else
291            m_native = nil;
292    }
293
294    if ((m_role != NoRole && !m_textSynced) || m_merged) {
295        QCocoaMenuBar *menubar = nullptr;
296        if (m_role == TextHeuristicRole) {
297            // Recognized menu roles are only found in the first menus below the menubar
298            QObject *p = menuParent();
299            int depth = 1;
300            while (depth < 3 && p && !(menubar = qobject_cast<QCocoaMenuBar *>(p))) {
301                ++depth;
302                QCocoaMenuObject *menuObject = dynamic_cast<QCocoaMenuObject *>(p);
303                Q_ASSERT(menuObject);
304                p = menuObject->menuParent();
305            }
306
307            if (menubar && depth < 3)
308                m_detectedRole = detectMenuRole(m_text);
309            else
310                m_detectedRole = NoRole;
311        }
312
313        QCocoaMenuLoader *loader = [QCocoaMenuLoader sharedMenuLoader];
314        NSMenuItem *mergeItem = nil;
315        const auto role = effectiveRole();
316        switch (role) {
317        case AboutRole:
318            mergeItem = [loader aboutMenuItem];
319            break;
320        case AboutQtRole:
321            mergeItem = [loader aboutQtMenuItem];
322            break;
323        case PreferencesRole:
324            mergeItem = [loader preferencesMenuItem];
325            break;
326        case ApplicationSpecificRole:
327            mergeItem = [loader appSpecificMenuItem:this];
328            break;
329        case QuitRole:
330            mergeItem = [loader quitMenuItem];
331            break;
332        case CutRole:
333        case CopyRole:
334        case PasteRole:
335        case SelectAllRole:
336            if (menubar)
337                mergeItem = menubar->itemForRole(role);
338            break;
339        case NoRole:
340            // The heuristic couldn't resolve the menu role
341            m_textSynced = false;
342            break;
343        default:
344            if (!m_text.isEmpty())
345                m_textSynced = true;
346            break;
347        }
348
349        if (mergeItem) {
350            m_textSynced = true;
351            m_merged = true;
352            [mergeItem retain];
353            [m_native release];
354            m_native = mergeItem;
355            if (auto *nativeItem = qt_objc_cast<QCocoaNSMenuItem *>(m_native))
356                nativeItem.platformMenuItem = this;
357        } else if (m_merged) {
358            // was previously merged, but no longer
359            [m_native release];
360            m_native = nil; // create item below
361            m_merged = false;
362        }
363    } else if (!m_text.isEmpty()) {
364        m_textSynced = true; // NoRole, and that was set explicitly. So, nothing to do anymore.
365    }
366
367    if (!m_native) {
368        m_native = [[QCocoaNSMenuItem alloc] initWithPlatformMenuItem:this];
369        m_native.title = m_text.toNSString();
370    }
371
372    resolveTargetAction();
373
374    m_native.hidden = !m_isVisible;
375    m_native.view = m_itemView;
376
377    QString text = mergeText();
378#ifndef QT_NO_SHORTCUT
379    QKeySequence accel = mergeAccel();
380
381    // Show multiple key sequences as part of the menu text.
382    if (accel.count() > 1)
383        text += QLatin1String(" (") + accel.toString(QKeySequence::NativeText) + QLatin1String(")");
384#endif
385
386    m_native.title = QPlatformTheme::removeMnemonics(text).toNSString();
387
388#ifndef QT_NO_SHORTCUT
389    if (accel.count() == 1) {
390        m_native.keyEquivalent = keySequenceToKeyEqivalent(accel);
391        m_native.keyEquivalentModifierMask = keySequenceModifierMask(accel);
392    } else
393#endif
394    {
395        m_native.keyEquivalent = @"";
396        m_native.keyEquivalentModifierMask = NSEventModifierFlagCommand;
397    }
398
399    m_native.image = [NSImage imageFromQIcon:m_icon withSize:m_iconSize];
400
401    m_native.state = m_checked ?  NSOnState : NSOffState;
402    return m_native;
403}
404
405QString QCocoaMenuItem::mergeText()
406{
407    QCocoaMenuLoader *loader = [QCocoaMenuLoader sharedMenuLoader];
408    if (m_native == [loader aboutMenuItem]) {
409        return qt_mac_applicationmenu_string(AboutAppMenuItem).arg(qt_mac_applicationName());
410    } else if (m_native== [loader aboutQtMenuItem]) {
411        if (m_text == QString("About Qt"))
412            return QCoreApplication::translate("QCocoaMenuItem", "About Qt");
413        else
414            return m_text;
415    } else if (m_native == [loader preferencesMenuItem]) {
416        return qt_mac_applicationmenu_string(PreferencesAppMenuItem);
417    } else if (m_native == [loader quitMenuItem]) {
418        return qt_mac_applicationmenu_string(QuitAppMenuItem).arg(qt_mac_applicationName());
419    } else if (m_text.contains('\t')) {
420        return m_text.left(m_text.indexOf('\t'));
421    }
422    return m_text;
423}
424
425#ifndef QT_NO_SHORTCUT
426QKeySequence QCocoaMenuItem::mergeAccel()
427{
428    QCocoaMenuLoader *loader = [QCocoaMenuLoader sharedMenuLoader];
429    if (m_native == [loader preferencesMenuItem])
430        return QKeySequence(QKeySequence::Preferences);
431    else if (m_native == [loader quitMenuItem])
432        return QKeySequence(QKeySequence::Quit);
433    else if (m_text.contains('\t'))
434        return QKeySequence(m_text.mid(m_text.indexOf('\t') + 1), QKeySequence::NativeText);
435
436    return m_shortcut;
437}
438#endif
439
440void QCocoaMenuItem::syncMerged()
441{
442    if (!m_merged) {
443        qWarning("Trying to sync a non-merged item");
444        return;
445    }
446
447    m_native.hidden = !m_isVisible;
448}
449
450void QCocoaMenuItem::setParentEnabled(bool enabled)
451{
452    if (m_parentEnabled != enabled) {
453        m_parentEnabled = enabled;
454        if (m_menu)
455            m_menu->propagateEnabledState(isEnabled());
456    }
457}
458
459QPlatformMenuItem::MenuRole QCocoaMenuItem::effectiveRole() const
460{
461    if (m_role > TextHeuristicRole)
462        return m_role;
463    else
464        return m_detectedRole;
465}
466
467void QCocoaMenuItem::setIconSize(int size)
468{
469    m_iconSize = size;
470}
471
472void QCocoaMenuItem::resolveTargetAction()
473{
474    if (m_native.separatorItem)
475        return;
476
477    // Some items created by QCocoaMenuLoader are not
478    // instances of QCocoaNSMenuItem and have their
479    // target/action set as Interface Builder would.
480    if (![m_native isMemberOfClass:[QCocoaNSMenuItem class]])
481        return;
482
483    // Use the responder chain and ensure native modal dialogs
484    // continue receiving cut/copy/paste/etc. key equivalents.
485    SEL roleAction;
486    switch (effectiveRole()) {
487    case CutRole:
488        roleAction = @selector(cut:);
489        break;
490    case CopyRole:
491        roleAction = @selector(copy:);
492        break;
493    case PasteRole:
494        roleAction = @selector(paste:);
495        break;
496    case SelectAllRole:
497        roleAction = @selector(selectAll:);
498        break;
499    default:
500        roleAction = @selector(qt_itemFired:);
501    }
502
503    m_native.action = roleAction;
504    m_native.target = nil;
505}
506
507QT_END_NAMESPACE
508