1/*
2 This file is part of the KDE project
3 SPDX-FileCopyrightText: 2021 Felix Ernst <fe.a.ernst@gmail.com>
4
5 SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
6*/
7
8#include "khamburgermenu.h"
9#include "khamburgermenu_p.h"
10
11#include "khamburgermenuhelpers_p.h"
12
13#include <KLocalizedString>
14
15#include <QMenu>
16#include <QMenuBar>
17#include <QStyle>
18#include <QToolBar>
19#include <QToolButton>
20
21#include <algorithm>
22#include <forward_list>
23#include <unordered_set>
24
25KHamburgerMenu::KHamburgerMenu(QObject *parent)
26 : QWidgetAction{parent}
27 , d_ptr{new KHamburgerMenuPrivate(this)}
28{
29}
30
31KHamburgerMenuPrivate::KHamburgerMenuPrivate(KHamburgerMenu *qq)
32 : q_ptr{qq}
33 , m_listeners{new ListenerContainer(this)}
34{
35 q_ptr->setPriority(QAction::LowPriority);
36 connect(sender: q_ptr, signal: &QAction::changed, context: this, slot: &KHamburgerMenuPrivate::slotActionChanged);
37 connect(sender: q_ptr, signal: &QAction::triggered, context: this, slot: &KHamburgerMenuPrivate::slotActionTriggered);
38}
39
40KHamburgerMenu::~KHamburgerMenu() = default;
41
42KHamburgerMenuPrivate::~KHamburgerMenuPrivate() = default;
43
44void KHamburgerMenu::setMenuBar(QMenuBar *menuBar)
45{
46 Q_D(KHamburgerMenu);
47 d->setMenuBar(menuBar);
48}
49
50void KHamburgerMenuPrivate::setMenuBar(QMenuBar *menuBar)
51{
52 if (m_menuBar) {
53 m_menuBar->removeEventFilter(obj: m_listeners->get<VisibilityChangesListener>());
54 m_menuBar->removeEventFilter(obj: m_listeners->get<AddOrRemoveActionListener>());
55 }
56 m_menuBar = menuBar;
57 updateVisibility();
58 if (m_menuBar) {
59 m_menuBar->installEventFilter(filterObj: m_listeners->get<VisibilityChangesListener>());
60 m_menuBar->installEventFilter(filterObj: m_listeners->get<AddOrRemoveActionListener>());
61 }
62}
63
64QMenuBar *KHamburgerMenu::menuBar() const
65{
66 Q_D(const KHamburgerMenu);
67 return d->menuBar();
68}
69
70QMenuBar *KHamburgerMenuPrivate::menuBar() const
71{
72 return m_menuBar;
73}
74
75void KHamburgerMenu::setMenuBarAdvertised(bool advertise)
76{
77 Q_D(KHamburgerMenu);
78 d->setMenuBarAdvertised(advertise);
79}
80
81void KHamburgerMenuPrivate::setMenuBarAdvertised(bool advertise)
82{
83 m_advertiseMenuBar = advertise;
84}
85
86bool KHamburgerMenu::menuBarAdvertised() const
87{
88 Q_D(const KHamburgerMenu);
89 return d->menuBarAdvertised();
90}
91
92bool KHamburgerMenuPrivate::menuBarAdvertised() const
93{
94 return m_advertiseMenuBar;
95}
96
97void KHamburgerMenu::setShowMenuBarAction(QAction *showMenuBarAction)
98{
99 Q_D(KHamburgerMenu);
100 d->setShowMenuBarAction(showMenuBarAction);
101}
102
103void KHamburgerMenuPrivate::setShowMenuBarAction(QAction *showMenuBarAction)
104{
105 m_showMenuBarAction = showMenuBarAction;
106}
107
108void KHamburgerMenu::addToMenu(QMenu *menu)
109{
110 Q_D(KHamburgerMenu);
111 d->insertIntoMenuBefore(menu, before: nullptr);
112}
113
114void KHamburgerMenu::insertIntoMenuBefore(QMenu *menu, QAction *before)
115{
116 Q_D(KHamburgerMenu);
117 d->insertIntoMenuBefore(menu, before);
118}
119
120void KHamburgerMenuPrivate::insertIntoMenuBefore(QMenu *menu, QAction *before)
121{
122 Q_CHECK_PTR(menu);
123 Q_Q(KHamburgerMenu);
124 if (!m_menuAction) {
125 m_menuAction = new QAction(this);
126 m_menuAction->setText(i18nc("@action:inmenu General purpose menu", "&Menu"));
127 m_menuAction->setIcon(q->icon());
128 m_menuAction->setMenu(m_actualMenu.get());
129 }
130 updateVisibility(); // Sets the appropriate visibility of m_menuAction.
131
132 menu->insertAction(before, action: m_menuAction);
133 connect(sender: menu, signal: &QMenu::aboutToShow, context: this, slot: [this, menu, q]() {
134 if (m_menuAction->isVisible()) {
135 Q_EMIT q->aboutToShowMenu();
136 hideActionsOf(widget: menu);
137 resetMenu();
138 }
139 });
140}
141
142void KHamburgerMenu::hideActionsOf(QWidget *widget)
143{
144 Q_D(KHamburgerMenu);
145 d->hideActionsOf(widget);
146}
147
148void KHamburgerMenuPrivate::hideActionsOf(QWidget *widget)
149{
150 Q_CHECK_PTR(widget);
151 m_widgetsWithActionsToBeHidden.remove(val: nullptr);
152 if (listContainsWidget(list: m_widgetsWithActionsToBeHidden, widget)) {
153 return;
154 }
155 m_widgetsWithActionsToBeHidden.emplace_front(args: QPointer<const QWidget>(widget));
156 if (QMenu *menu = qobject_cast<QMenu *>(object: widget)) {
157 // QMenus are normally hidden. This will avoid redundancy with their actions anyways.
158 menu->installEventFilter(filterObj: m_listeners->get<AddOrRemoveActionListener>());
159 notifyMenuResetNeeded();
160 } else {
161 // Only avoid redundancy when the widget is visible.
162 widget->installEventFilter(filterObj: m_listeners->get<VisibleActionsChangeListener>());
163 if (widget->isVisible()) {
164 notifyMenuResetNeeded();
165 }
166 }
167}
168
169void KHamburgerMenu::showActionsOf(QWidget *widget)
170{
171 Q_D(KHamburgerMenu);
172 d->showActionsOf(widget);
173}
174
175void KHamburgerMenuPrivate::showActionsOf(QWidget *widget)
176{
177 Q_CHECK_PTR(widget);
178 m_widgetsWithActionsToBeHidden.remove(val: widget);
179 widget->removeEventFilter(obj: m_listeners->get<AddOrRemoveActionListener>());
180 widget->removeEventFilter(obj: m_listeners->get<VisibleActionsChangeListener>());
181 if (isWidgetActuallyVisible(widget)) {
182 notifyMenuResetNeeded();
183 }
184}
185
186QWidget *KHamburgerMenu::createWidget(QWidget *parent)
187{
188 Q_D(KHamburgerMenu);
189 return d->createWidget(parent);
190}
191
192QWidget *KHamburgerMenuPrivate::createWidget(QWidget *parent)
193{
194 if (qobject_cast<QMenu *>(object: parent)) {
195 qDebug(
196 msg: "Adding a KHamburgerMenu directly to a QMenu. "
197 "This will look odd. Use addToMenu() instead.");
198 }
199 Q_Q(KHamburgerMenu);
200
201 auto toolButton = new QToolButton(parent);
202 // Set appearance
203 toolButton->setDefaultAction(q);
204 toolButton->setMenu(m_actualMenu.get());
205 toolButton->setAttribute(Qt::WidgetAttribute::WA_CustomWhatsThis);
206 toolButton->setPopupMode(QToolButton::InstantPopup);
207 updateButtonStyle(hamburgerMenuButton: toolButton);
208 if (const QToolBar *toolbar = qobject_cast<QToolBar *>(object: parent)) {
209 connect(sender: toolbar, signal: &QToolBar::toolButtonStyleChanged, context: toolButton, slot: &QToolButton::setToolButtonStyle);
210 }
211
212 setToolButtonVisible(toolButton, visible: !isMenuBarVisible(menuBar: m_menuBar));
213
214 // Make sure the menu will be ready in time
215 toolButton->installEventFilter(filterObj: m_listeners->get<ButtonPressListener>());
216
217 hideActionsOf(widget: parent);
218 return toolButton;
219}
220
221QAction *KHamburgerMenuPrivate::actionWithExclusivesFrom(QAction *from, QWidget *parent, std::unordered_set<const QAction *> &nonExclusives) const
222{
223 Q_CHECK_PTR(from);
224 if (nonExclusives.count(x: from) > 0) {
225 return nullptr; // The action is non-exclusive/already visible elsewhere.
226 }
227 if (!from->menu() || from->menu()->isEmpty()) {
228 return from; // The action is exclusive and doesn't have a menu.
229 }
230 std::unique_ptr<QAction> menuActionWithExclusives(new QAction(from->icon(), from->text(), parent));
231 std::unique_ptr<QMenu> menuWithExclusives(new QMenu(parent));
232 const auto fromMenuActions = from->menu()->actions();
233 for (QAction *action : fromMenuActions) {
234 QAction *actionWithExclusives = actionWithExclusivesFrom(from: action, parent: menuWithExclusives.get(), nonExclusives);
235 if (actionWithExclusives) {
236 menuWithExclusives->addAction(action: actionWithExclusives);
237 }
238 }
239 if (menuWithExclusives->isEmpty()) {
240 return nullptr; // "from" has a menu that contains zero exclusive actions.
241 // There is a chance that "from" is an exclusive action itself and should
242 // therefore be returned instead but that is unlikely for an action that has a menu().
243 // This fringe case is the only one that can't be correctly covered because we can
244 // not know or assume that activating the action does something or if it is nothing
245 // but a container for a menu.
246 }
247 menuActionWithExclusives->setMenu(menuWithExclusives.release());
248 return menuActionWithExclusives.release();
249}
250
251std::unique_ptr<QMenu> KHamburgerMenuPrivate::newMenu()
252{
253 std::unique_ptr<QMenu> menu(new QMenu());
254 Q_Q(const KHamburgerMenu);
255
256 // Make sure we notice if the q->menu() is changed or replaced in the future.
257 if (q->menu() != m_lastUsedMenu) {
258 q->menu()->installEventFilter(filterObj: m_listeners->get<AddOrRemoveActionListener>());
259
260 if (m_lastUsedMenu && !listContainsWidget(list: m_widgetsWithActionsToBeHidden, widget: m_lastUsedMenu)) {
261 m_lastUsedMenu->removeEventFilter(obj: m_listeners->get<AddOrRemoveActionListener>());
262 }
263 m_lastUsedMenu = q->menu();
264 }
265
266 if (!q->menu() && !m_menuBar) {
267 return menu; // empty menu
268 }
269
270 if (!q->menu()) {
271 // We have nothing else to work with so let's just add the menuBar contents.
272 const auto menuBarActions = m_menuBar->actions();
273 for (QAction *menuAction : menuBarActions) {
274 menu->addAction(action: menuAction);
275 }
276 return menu;
277 }
278
279 // Collect actions which shouldn't be added to the menu
280 std::unordered_set<const QAction *> visibleActions;
281 m_widgetsWithActionsToBeHidden.remove(val: nullptr);
282 for (const QWidget *widget : m_widgetsWithActionsToBeHidden) {
283 if (qobject_cast<const QMenu *>(object: widget) || isWidgetActuallyVisible(widget)) {
284 // avoid redundancy with menus even when they are not actually visible.
285 visibleActions.reserve(n: visibleActions.size() + widget->actions().size());
286 const auto widgetActions = widget->actions();
287 for (QAction *action : widgetActions) {
288 visibleActions.insert(x: action);
289 }
290 }
291 }
292 // Populate the menu
293 const auto menuActions = q->menu()->actions();
294 for (QAction *action : menuActions) {
295 if (visibleActions.count(x: action) == 0) {
296 menu->addAction(action);
297 visibleActions.insert(x: action);
298 }
299 }
300 // Add the last two menu actions
301 if (m_menuBar) {
302 connect(sender: menu.get(), signal: &QMenu::aboutToShow, context: this, slot: [this]() {
303 if (m_menuBar->actions().last()->icon().isNull()) {
304 m_helpIconIsSet = false;
305 m_menuBar->actions().last()->setIcon(QIcon::fromTheme(QStringLiteral("help-contents"))); // set "Help" menu icon
306 } else {
307 m_helpIconIsSet = true; // if the "Help" icon was set by the application, we want to leave it untouched
308 }
309 });
310 connect(sender: menu.get(), signal: &QMenu::aboutToHide, context: this, slot: [this]() {
311 if (m_menuBar->actions().last()->icon().name() == QStringLiteral("help-contents") && !m_helpIconIsSet) {
312 m_menuBar->actions().last()->setIcon(QIcon());
313 }
314 });
315 menu->addAction(action: m_menuBar->actions().last()); // add "Help" menu
316 visibleActions.insert(x: m_menuBar->actions().last());
317 if (m_advertiseMenuBar) {
318 menu->addSeparator();
319 m_menuBarAdvertisementMenu = newMenuBarAdvertisementMenu(visibleActions);
320 menu->addAction(action: m_menuBarAdvertisementMenu->menuAction());
321 }
322 }
323 return menu;
324}
325
326std::unique_ptr<QMenu> KHamburgerMenuPrivate::newMenuBarAdvertisementMenu(std::unordered_set<const QAction *> &visibleActions)
327{
328 std::unique_ptr<QMenu> advertiseMenuBarMenu(new QMenu());
329 m_showMenuBarWithAllActionsText = i18nc("@action:inmenu A menu item that advertises and enables the menubar", "Show &Menubar with All Actions");
330 connect(sender: advertiseMenuBarMenu.get(), signal: &QMenu::aboutToShow, context: this, slot: [this]() {
331 if (m_showMenuBarAction) {
332 m_showMenuBarText = m_showMenuBarAction->text();
333 m_showMenuBarAction->setText(m_showMenuBarWithAllActionsText);
334 }
335 });
336 connect(sender: advertiseMenuBarMenu.get(), signal: &QMenu::aboutToHide, context: this, slot: [this]() {
337 if (m_showMenuBarAction && m_showMenuBarAction->text() == m_showMenuBarWithAllActionsText) {
338 m_showMenuBarAction->setText(m_showMenuBarText);
339 }
340 });
341 if (m_showMenuBarAction) {
342 advertiseMenuBarMenu->addAction(action: m_showMenuBarAction);
343 visibleActions.insert(x: m_showMenuBarAction);
344 }
345 QAction *section = advertiseMenuBarMenu->addSeparator();
346
347 const auto menuBarActions = m_menuBar->actions();
348 for (QAction *menuAction : menuBarActions) {
349 QAction *menuActionWithExclusives = actionWithExclusivesFrom(from: menuAction, parent: advertiseMenuBarMenu.get(), nonExclusives&: visibleActions);
350 if (menuActionWithExclusives) {
351 advertiseMenuBarMenu->addAction(action: menuActionWithExclusives);
352 }
353 }
354 advertiseMenuBarMenu->setIcon(QIcon::fromTheme(QStringLiteral("view-more-symbolic")));
355 advertiseMenuBarMenu->setTitle(i18nc("@action:inmenu A menu text advertising its contents (more features).", "More"));
356 section->setText(i18nc("@action:inmenu A section heading advertising the contents of the menu bar", "More Actions"));
357 return advertiseMenuBarMenu;
358}
359
360void KHamburgerMenuPrivate::resetMenu()
361{
362 Q_Q(KHamburgerMenu);
363 if (!m_menuResetNeeded && m_actualMenu && m_lastUsedMenu == q->menu()) {
364 return;
365 }
366 m_menuResetNeeded = false;
367
368 m_actualMenu = newMenu();
369
370 const auto createdWidgets = q->createdWidgets();
371 for (auto widget : createdWidgets) {
372 static_cast<QToolButton *>(widget)->setMenu(m_actualMenu.get());
373 }
374 if (m_menuAction) {
375 m_menuAction->setMenu(m_actualMenu.get());
376 }
377}
378
379void KHamburgerMenuPrivate::updateVisibility()
380{
381 Q_Q(KHamburgerMenu);
382 /** The visibility of KHamburgerMenu should be opposite to the visibility of m_menuBar.
383 * Exception: We only consider a visible m_menuBar as actually visible if it is not a native
384 * menu bar because native menu bars can come in many shapes and sizes which don't necessarily
385 * have the same usability benefits as a traditional in-window menu bar.
386 * KDE applications normally allow the user to remove any actions from their toolbar(s) anyway. */
387 const bool menuBarVisible = isMenuBarVisible(menuBar: m_menuBar);
388
389 const auto createdWidgets = q->createdWidgets();
390 for (auto widget : createdWidgets) {
391 setToolButtonVisible(toolButton: widget, visible: !menuBarVisible);
392 }
393
394 if (!m_menuAction) {
395 if (menuBarVisible && m_actualMenu) {
396 m_actualMenu.release()->deleteLater(); // might as well free up some memory
397 }
398 return;
399 }
400
401 // The m_menuAction acts as a fallback if both the m_menuBar and all createdWidgets() on the UI
402 // are currently hidden. Only then should the m_menuAction ever be visible in a QMenu.
403 if (menuBarVisible || (m_menuBar && m_menuBar->isNativeMenuBar()) // See [1] below.
404 || std::any_of(first: createdWidgets.cbegin(), last: createdWidgets.cend(), pred: isWidgetActuallyVisible)) {
405 m_menuAction->setVisible(false);
406 return;
407 }
408 m_menuAction->setVisible(true);
409
410 // [1] While the m_menuAction can be used as a normal menu by users that don't mind invoking a
411 // QMenu to access any menu actions, its primary use really is that of a fallback.
412 // Therefore the existence of a native menu bar (no matter what shape or size it might have)
413 // is enough reason for us to hide m_menuAction.
414}
415
416void KHamburgerMenuPrivate::slotActionChanged()
417{
418 Q_Q(KHamburgerMenu);
419 const auto createdWidgets = q->createdWidgets();
420 for (auto widget : createdWidgets) {
421 auto toolButton = static_cast<QToolButton *>(widget);
422 updateButtonStyle(hamburgerMenuButton: toolButton);
423 }
424}
425
426void KHamburgerMenuPrivate::slotActionTriggered()
427{
428 if (isMenuBarVisible(menuBar: m_menuBar)) {
429 const auto menuBarActions = m_menuBar->actions();
430 for (const auto action : menuBarActions) {
431 if (action->isEnabled() && !action->isSeparator()) {
432 m_menuBar->setActiveAction(m_menuBar->actions().constFirst());
433 return;
434 }
435 }
436 }
437
438 Q_Q(KHamburgerMenu);
439 const auto createdWidgets = q->createdWidgets();
440 for (auto widget : createdWidgets) {
441 if (isWidgetActuallyVisible(widget) && widget->isActiveWindow()) {
442 auto toolButton = static_cast<QToolButton *>(widget);
443 m_listeners->get<ButtonPressListener>()->prepareHamburgerButtonForPress(button: toolButton);
444 toolButton->pressed();
445 return;
446 }
447 }
448
449 Q_EMIT q->aboutToShowMenu();
450 resetMenu();
451 prepareParentlessMenuForShowing(menu: m_actualMenu.get(), surrogateParent: nullptr);
452 m_actualMenu->popup(pos: QCursor::pos());
453}
454
455void KHamburgerMenuPrivate::updateButtonStyle(QToolButton *hamburgerMenuButton) const
456{
457 Q_Q(const KHamburgerMenu);
458 Qt::ToolButtonStyle buttonStyle = Qt::ToolButtonFollowStyle;
459 if (QToolBar *toolbar = qobject_cast<QToolBar *>(object: hamburgerMenuButton->parent())) {
460 buttonStyle = toolbar->toolButtonStyle();
461 }
462 if (buttonStyle == Qt::ToolButtonFollowStyle) {
463 buttonStyle = static_cast<Qt::ToolButtonStyle>(hamburgerMenuButton->style()->styleHint(stylehint: QStyle::SH_ToolButtonStyle));
464 }
465 if (buttonStyle == Qt::ToolButtonTextBesideIcon && q->priority() < QAction::NormalPriority) {
466 hamburgerMenuButton->setToolButtonStyle(Qt::ToolButtonIconOnly);
467 } else {
468 hamburgerMenuButton->setToolButtonStyle(buttonStyle);
469 }
470}
471
472#include "moc_khamburgermenu.cpp"
473#include "moc_khamburgermenu_p.cpp"
474

source code of kconfigwidgets/src/khamburgermenu.cpp