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