1 /*
2  * DesktopMenuCallback.cpp
3  *
4  * Copyright (C) 2021 by RStudio, PBC
5  *
6  * Unless you have received this program directly from RStudio pursuant
7  * to the terms of a commercial license agreement with RStudio, then
8  * this program is licensed to you under the terms of version 3 of the
9  * GNU Affero General Public License. This program is distributed WITHOUT
10  * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT,
11  * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the
12  * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details.
13  *
14  */
15 
16 #include "DesktopMenuCallback.hpp"
17 
18 #include <core/Algorithm.hpp>
19 
20 #include <QDebug>
21 #include <QApplication>
22 #include <QWindow>
23 
24 namespace rstudio {
25 namespace desktop {
26 
MenuCallback(QObject * parent)27 MenuCallback::MenuCallback(QObject *parent) :
28     QObject(parent)
29 {
30 }
31 
beginMainMenu()32 void MenuCallback::beginMainMenu()
33 {
34    pMainMenu_ = new QMenuBar();
35 }
36 
beginMenu(QString label)37 void MenuCallback::beginMenu(QString label)
38 {
39 #ifdef Q_OS_MAC
40    if (label == QString::fromUtf8("&Help"))
41    {
42       pMainMenu_->addMenu(new WindowMenu(pMainMenu_));
43    }
44 #endif
45 
46    auto* pMenu = new SubMenu(label, pMainMenu_);
47    pMenu->setSeparatorsCollapsible(true);
48 
49    if (menuStack_.count() == 0)
50       pMainMenu_->addMenu(pMenu);
51    else
52       menuStack_.top()->addMenu(pMenu);
53 
54    menuStack_.push(pMenu);
55 }
56 
addCustomAction(QString commandId,QString label,QString tooltip,QKeySequence keySequence,bool checkable)57 QAction* MenuCallback::addCustomAction(QString commandId,
58                                        QString label,
59                                        QString tooltip,
60                                        QKeySequence keySequence,
61                                        bool checkable)
62 {
63 
64    QAction* pAction = nullptr;
65 
66 #ifdef Q_OS_MAC
67    // On Mac, certain commands will be automatically moved to Application Menu by Qt. If we want them to also
68    // appear in RStudio menus, check for them here and return nullptr.
69    if (duplicateAppMenuAction(QString::fromUtf8("showAboutDialog"),
70                               commandId, label, tooltip, keySequence, checkable))
71    {
72       return nullptr;
73    }
74    else if (duplicateAppMenuAction(QString::fromUtf8("quitSession"),
75                               commandId, label, tooltip, keySequence, checkable))
76    {
77       return nullptr;
78    }
79 
80    // If we want a command to not be automatically moved to Application Menu, include it here and return the
81    // created action.
82    pAction = duplicateAppMenuAction(QString::fromUtf8("buildToolsProjectSetup"),
83                                     commandId, label, tooltip, keySequence, checkable);
84    if (pAction)
85       return pAction;
86 
87 #endif // Q_OS_MAC
88 
89    // this silly branch exists just so one doesn't have to worry about whether
90    // we should be prefixing with 'else' in later conditionals
91    if (false)
92    {
93    }
94 
95    // on macOS, these bindings are hooked up on the GWT side (mainly to ensure
96    // that zoom requests targetted to a GWT window work as expected)
97 #ifndef Q_OS_MAC
98    else if (commandId == QStringLiteral("zoomActualSize"))
99    {
100       pAction = menuStack_.top()->addAction(
101                QIcon(),
102                label,
103                this,
104                SIGNAL(zoomActualSize()),
105                QKeySequence(Qt::CTRL + Qt::Key_0));
106    }
107    else if (commandId == QStringLiteral("zoomIn"))
108    {
109       pAction = menuStack_.top()->addAction(
110                QIcon(),
111                label,
112                this,
113                SIGNAL(zoomIn()),
114                QKeySequence::ZoomIn);
115    }
116    else if (commandId == QStringLiteral("zoomOut"))
117    {
118       pAction = menuStack_.top()->addAction(
119                QIcon(),
120                label,
121                this,
122                SIGNAL(zoomOut()),
123                QKeySequence::ZoomOut);
124    }
125 #endif
126 
127 #ifdef Q_OS_MAC
128    // NOTE: even though we seem to be using Meta as a modifier key here, Qt
129    // will translate that to CTRL (but only for Ctrl+Tab and Ctrl+Shift+Tab)
130    // TODO: using actionInvoke() also flashes the menu bar; that feels a little
131    // too aggressive for this command?
132    else if (commandId == QStringLiteral("nextTab"))
133    {
134       pAction = menuStack_.top()->addAction(
135                QIcon(),
136                label,
137                this,
138                SLOT(actionInvoked()),
139                QKeySequence(Qt::META + Qt::Key_Tab));
140    }
141    else if (commandId == QStringLiteral("previousTab"))
142    {
143       pAction = menuStack_.top()->addAction(
144                QIcon(),
145                label,
146                this,
147                SLOT(actionInvoked()),
148                QKeySequence(Qt::SHIFT + Qt::META + Qt::Key_Tab));
149    }
150 #endif
151 #ifdef Q_OS_LINUX
152    else if (commandId == QString::fromUtf8("nextTab"))
153    {
154       pAction = menuStack_.top()->addAction(QIcon(),
155                                             label,
156                                             this,
157                                             SLOT(actionInvoked()),
158                                             QKeySequence(Qt::CTRL +
159                                                          Qt::Key_PageDown));
160    }
161    else if (commandId == QString::fromUtf8("previousTab"))
162    {
163       pAction = menuStack_.top()->addAction(QIcon(),
164                                             label,
165                                             this,
166                                             SLOT(actionInvoked()),
167                                             QKeySequence(Qt::CTRL +
168                                                          Qt::Key_PageUp));
169    }
170 #endif
171 
172    if (pAction != nullptr)
173    {
174       pAction->setData(commandId);
175       pAction->setToolTip(tooltip);
176       return pAction;
177    }
178    else
179    {
180       return nullptr;
181    }
182 }
183 
duplicateAppMenuAction(QString commandToDuplicate,QString commandId,QString label,QString tooltip,QKeySequence keySequence,bool checkable)184 QAction* MenuCallback::duplicateAppMenuAction(QString commandToDuplicate,
185                                               QString commandId,
186                                               QString label,
187                                               QString tooltip,
188                                               QKeySequence keySequence,
189                                               bool checkable)
190 {
191    QAction* pAction = nullptr;
192    if (commandId == commandToDuplicate)
193    {
194       pAction = new QAction(QIcon(), label);
195       pAction->setMenuRole(QAction::NoRole);
196       pAction->setData(commandId);
197       pAction->setToolTip(tooltip);
198       pAction->setShortcut(keySequence);
199       if (checkable)
200          pAction->setCheckable(true);
201 
202       menuStack_.top()->addAction(pAction);
203 
204       auto* pBinder = new MenuActionBinder(menuStack_.top(), pAction);
205       connect(pBinder, SIGNAL(manageCommand(QString, QAction * )), this, SIGNAL(manageCommand(QString, QAction * )));
206       connect(pAction, SIGNAL(triggered()), this, SLOT(actionInvoked()));
207    }
208    return pAction;
209 }
210 
addCommand(QString commandId,QString label,QString tooltip,QString shortcut,bool checkable)211 void MenuCallback::addCommand(QString commandId,
212                               QString label,
213                               QString tooltip,
214                               QString shortcut,
215                               bool checkable)
216 {
217 
218 #ifdef Q_OS_MAC
219    // on macOS, replace instances of 'Ctrl' with 'Meta'; QKeySequence renders "Ctrl" using the
220    // macOS command symbol, but we want the menu to show the literal Ctrl symbol (^)
221    shortcut.replace(QStringLiteral("Ctrl"), QStringLiteral("Meta"));
222 
223    // on Mac the enter key and return keys have different symbols
224    // https://github.com/rstudio/rstudio/issues/6524
225    shortcut.replace(QStringLiteral("Enter"), QStringLiteral("Return"));
226 #endif
227 
228    // replace instances of 'Cmd' with 'Ctrl' -- note that on macOS
229    // Qt automatically maps that to the Command key
230    shortcut.replace(QStringLiteral("Cmd"), QStringLiteral("Ctrl"));
231 
232    QKeySequence keySequence(shortcut);
233 
234    // some shortcuts (namely, the Edit shortcuts) don't have bindings on the client side.
235    // populate those here when discovered
236    if (commandId == QStringLiteral("cutDummy"))
237    {
238       keySequence = QKeySequence(QKeySequence::Cut);
239    }
240    else if (commandId == QStringLiteral("copyDummy"))
241    {
242       keySequence = QKeySequence(QKeySequence::Copy);
243    }
244    else if (commandId == QStringLiteral("pasteDummy"))
245    {
246       keySequence = QKeySequence(QKeySequence::Paste);
247    }
248    else if (commandId == QStringLiteral("pasteWithIndentDummy"))
249    {
250       keySequence = QKeySequence(Qt::CTRL + Qt::SHIFT + Qt::Key_V);
251    }
252    else if (commandId == QStringLiteral("undoDummy"))
253    {
254       keySequence = QKeySequence(QKeySequence::Undo);
255    }
256    else if (commandId == QStringLiteral("redoDummy"))
257    {
258       keySequence = QKeySequence(QKeySequence::Redo);
259    }
260 
261 #ifndef Q_OS_MAC
262    if (shortcut.contains(QString::fromUtf8("\n")))
263    {
264       int value = (keySequence[0] & Qt::MODIFIER_MASK) + Qt::Key_Enter;
265       keySequence = QKeySequence(value);
266    }
267 #endif
268 
269    // allow custom action handlers first shot
270    QPointer<QAction> pAction =
271          addCustomAction(commandId, label, tooltip, keySequence, checkable);
272 
273    // if there was no custom handler then do stock command-id processing
274    if (pAction == nullptr)
275    {
276       pAction = menuStack_.top()->addAction(QIcon(),
277                                             label,
278                                             this,
279                                             SLOT(actionInvoked()),
280                                             keySequence);
281       pAction->setData(commandId);
282       pAction->setToolTip(tooltip);
283       if (checkable)
284          pAction->setCheckable(true);
285 
286       auto * pBinder = new MenuActionBinder(menuStack_.top(), pAction);
287       connect(pBinder, SIGNAL(manageCommand(QString,QAction*)),
288               this, SIGNAL(manageCommand(QString,QAction*)));
289    }
290 
291    // remember action for later
292    actions_[commandId].push_back(pAction);
293 }
294 
actionInvoked()295 void MenuCallback::actionInvoked()
296 {
297    auto * action = qobject_cast<QAction*>(sender());
298    QString commandId = action->data().toString();
299    commandInvoked(commandId);
300 }
301 
addSeparator()302 void MenuCallback::addSeparator()
303 {
304    if (menuStack_.count() > 0)
305       menuStack_.top()->addSeparator();
306 }
307 
endMenu()308 void MenuCallback::endMenu()
309 {
310    menuStack_.pop();
311 }
312 
endMainMenu()313 void MenuCallback::endMainMenu()
314 {
315    menuBarCompleted(pMainMenu_);
316 }
317 
318 namespace {
319 
320 template <typename T, typename F>
setCommandProperty(T & actions,QString commandId,F && setter)321 void setCommandProperty(T& actions, QString commandId, F&& setter)
322 {
323    auto it = actions.find(commandId);
324    if (it == actions.end())
325        return;
326 
327    // NOTE: in some cases actions from a previous RStudio session
328    // can leak into the map; we normally prune those each time a
329    // new page is loaded but just to be careful we validate that
330    // we have non-null pointers before operating on them
331    for (auto& pAction : it.value())
332       if (pAction)
333          setter(pAction);
334 }
335 
336 } // end anonymous namespace
337 
setCommandEnabled(QString commandId,bool enabled)338 void MenuCallback::setCommandEnabled(QString commandId, bool enabled)
339 {
340    setCommandProperty(actions_, commandId, [=](QPointer<QAction> pAction) {
341       pAction->setEnabled(enabled);
342    });
343 }
344 
setCommandVisible(QString commandId,bool visible)345 void MenuCallback::setCommandVisible(QString commandId, bool visible)
346 {
347    setCommandProperty(actions_, commandId, [=](QPointer<QAction> pAction) {
348       pAction->setVisible(visible);
349    });
350 }
351 
setCommandLabel(QString commandId,QString label)352 void MenuCallback::setCommandLabel(QString commandId, QString label)
353 {
354    setCommandProperty(actions_, commandId, [=](QPointer<QAction> pAction) {
355       pAction->setText(label);
356    });
357 }
358 
setCommandChecked(QString commandId,bool checked)359 void MenuCallback::setCommandChecked(QString commandId, bool checked)
360 {
361    setCommandProperty(actions_, commandId, [=](QPointer<QAction> pAction) {
362       pAction->setChecked(checked);
363    });
364 }
365 
setMainMenuEnabled(bool enabled)366 void MenuCallback::setMainMenuEnabled(bool enabled)
367 {
368    if (pMainMenu_)
369       pMainMenu_->setEnabled(enabled);
370 }
371 
cleanUpActions()372 void MenuCallback::cleanUpActions()
373 {
374    for (auto& actions : actions_.values())
375    {
376       core::algorithm::expel_if(actions, [](QPointer<QAction> pAction) {
377          return pAction.isNull();
378       });
379    }
380 }
381 
MenuActionBinder(QMenu * pMenu,QAction * pAction)382 MenuActionBinder::MenuActionBinder(QMenu* pMenu, QAction* pAction) : QObject(pAction)
383 {
384    connect(pMenu, SIGNAL(aboutToShow()), this, SLOT(onShowMenu()));
385    connect(pMenu, SIGNAL(aboutToHide()), this, SLOT(onHideMenu()));
386    pAction_ = pAction;
387    keySequence_ = pAction->shortcut();
388    pAction->setShortcut(QKeySequence());
389 }
390 
onShowMenu()391 void MenuActionBinder::onShowMenu()
392 {
393    QString commandId = pAction_->data().toString();
394    pAction_->setShortcut(keySequence_);
395 }
396 
onHideMenu()397 void MenuActionBinder::onHideMenu()
398 {
399    pAction_->setShortcut(QKeySequence());
400 }
401 
WindowMenu(QWidget * parent)402 WindowMenu::WindowMenu(QWidget *parent) : QMenu(QString::fromUtf8("&Window"), parent)
403 {
404    // NOTE: CTRL means META on macOS
405    pMinimize_ = addAction(QString::fromUtf8("Minimize"));
406    pMinimize_->setShortcut(Qt::CTRL + Qt::Key_M);
407    connect(pMinimize_, SIGNAL(triggered()),
408            this, SLOT(onMinimize()));
409 
410    pZoom_ = addAction(QString::fromUtf8("Zoom"));
411    connect(pZoom_, SIGNAL(triggered()),
412            this, SLOT(onZoom()));
413 
414    addSeparator();
415 
416    pWindowPlaceholder_ = addAction(QString::fromUtf8("__PLACEHOLDER__"));
417    pWindowPlaceholder_->setVisible(false);
418 
419    addSeparator();
420 
421    pBringAllToFront_ = addAction(QString::fromUtf8("Bring All to Front"));
422    connect(pBringAllToFront_, SIGNAL(triggered()),
423            this, SLOT(onBringAllToFront()));
424 
425    connect(this, SIGNAL(aboutToShow()),
426            this, SLOT(onAboutToShow()));
427    connect(this, SIGNAL(aboutToHide()),
428            this, SLOT(onAboutToHide()));
429 }
430 
onMinimize()431 void WindowMenu::onMinimize()
432 {
433    QWidget* pWin = QApplication::activeWindow();
434    if (pWin)
435    {
436       pWin->setWindowState(Qt::WindowMinimized);
437    }
438 }
439 
onZoom()440 void WindowMenu::onZoom()
441 {
442    QWidget* pWin = QApplication::activeWindow();
443    if (pWin)
444    {
445       pWin->setWindowState(pWin->windowState() ^ Qt::WindowMaximized);
446    }
447 }
448 
onBringAllToFront()449 void WindowMenu::onBringAllToFront()
450 {
451 #ifdef Q_OS_MAC
452    for (QWindow* appWindow : qApp->allWindows())
453    {
454       appWindow->raise();
455    }
456 #endif
457 }
458 
onAboutToShow()459 void WindowMenu::onAboutToShow()
460 {
461    QWidget* win = QApplication::activeWindow();
462    pMinimize_->setEnabled(win);
463    pZoom_->setEnabled(win && win->maximumSize() != win->minimumSize());
464    pBringAllToFront_->setEnabled(win);
465 
466 
467    for (int i = windows_.size() - 1; i >= 0; i--)
468    {
469       QAction* pAction = windows_[i];
470       removeAction(pAction);
471       windows_.removeAt(i);
472       pAction->deleteLater();
473    }
474 
475    QWidgetList topLevels = QApplication::topLevelWidgets();
476    for (auto pWindow : topLevels)
477    {
478       if (!pWindow->isVisible())
479          continue;
480 
481       // construct with no parent (we free it manually)
482       QAction* pAction = new QAction(pWindow->windowTitle(), nullptr);
483       pAction->setData(QVariant::fromValue(pWindow));
484       pAction->setCheckable(true);
485       if (pWindow->isActiveWindow())
486          pAction->setChecked(true);
487       insertAction(pWindowPlaceholder_, pAction);
488       connect(pAction, SIGNAL(triggered()),
489               this, SLOT(showWindow()));
490 
491       windows_.append(pAction);
492    }
493 }
494 
onAboutToHide()495 void WindowMenu::onAboutToHide()
496 {
497 }
498 
showWindow()499 void WindowMenu::showWindow()
500 {
501    auto* pAction = qobject_cast<QAction*>(sender());
502    if (!pAction)
503       return;
504    auto* pWidget = pAction->data().value<QWidget*>();
505    if (!pWidget)
506       return;
507    if (pWidget->isMinimized())
508       pWidget->setWindowState(pWidget->windowState() & ~Qt::WindowMinimized);
509    pWidget->activateWindow();
510    pWidget->raise();
511 }
512 
513 } // namespace desktop
514 } // namespace rstudio
515