File indexing completed on 2024-03-24 15:25:58

0001 /*
0002     This file is part of the KDE project
0003     SPDX-FileCopyrightText: 2021 Felix Ernst <fe.a.ernst@gmail.com>
0004 
0005     SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
0006 */
0007 
0008 #include "khamburgermenu.h"
0009 #include "khamburgermenu_p.h"
0010 
0011 #include "khamburgermenuhelpers_p.h"
0012 
0013 #include <KLocalizedString>
0014 
0015 #include <QMenu>
0016 #include <QMenuBar>
0017 #include <QStyle>
0018 #include <QToolBar>
0019 #include <QToolButton>
0020 
0021 #include <algorithm>
0022 #include <forward_list>
0023 #include <unordered_set>
0024 
0025 KHamburgerMenu::KHamburgerMenu(QObject *parent)
0026     : QWidgetAction{parent}
0027     , d_ptr{new KHamburgerMenuPrivate(this)}
0028 {
0029 }
0030 
0031 KHamburgerMenuPrivate::KHamburgerMenuPrivate(KHamburgerMenu *qq)
0032     : q_ptr{qq}
0033     , m_listeners{new ListenerContainer(this)}
0034 {
0035     q_ptr->setPriority(QAction::LowPriority);
0036     connect(q_ptr, &QAction::changed, this, &KHamburgerMenuPrivate::slotActionChanged);
0037 }
0038 
0039 KHamburgerMenu::~KHamburgerMenu() = default;
0040 
0041 KHamburgerMenuPrivate::~KHamburgerMenuPrivate() = default;
0042 
0043 void KHamburgerMenu::setMenuBar(QMenuBar *menuBar)
0044 {
0045     Q_D(KHamburgerMenu);
0046     d->setMenuBar(menuBar);
0047 }
0048 
0049 void KHamburgerMenuPrivate::setMenuBar(QMenuBar *menuBar)
0050 {
0051     if (m_menuBar) {
0052         m_menuBar->removeEventFilter(m_listeners->get<VisibilityChangesListener>());
0053         m_menuBar->removeEventFilter(m_listeners->get<AddOrRemoveActionListener>());
0054     }
0055     m_menuBar = menuBar;
0056     updateVisibility();
0057     if (m_menuBar) {
0058         m_menuBar->installEventFilter(m_listeners->get<VisibilityChangesListener>());
0059         m_menuBar->installEventFilter(m_listeners->get<AddOrRemoveActionListener>());
0060     }
0061 }
0062 
0063 QMenuBar *KHamburgerMenu::menuBar() const
0064 {
0065     Q_D(const KHamburgerMenu);
0066     return d->menuBar();
0067 }
0068 
0069 QMenuBar *KHamburgerMenuPrivate::menuBar() const
0070 {
0071     return m_menuBar;
0072 }
0073 
0074 void KHamburgerMenu::setMenuBarAdvertised(bool advertise)
0075 {
0076     Q_D(KHamburgerMenu);
0077     d->setMenuBarAdvertised(advertise);
0078 }
0079 
0080 void KHamburgerMenuPrivate::setMenuBarAdvertised(bool advertise)
0081 {
0082     m_advertiseMenuBar = advertise;
0083 }
0084 
0085 bool KHamburgerMenu::menuBarAdvertised() const
0086 {
0087     Q_D(const KHamburgerMenu);
0088     return d->menuBarAdvertised();
0089 }
0090 
0091 bool KHamburgerMenuPrivate::menuBarAdvertised() const
0092 {
0093     return m_advertiseMenuBar;
0094 }
0095 
0096 void KHamburgerMenu::setShowMenuBarAction(QAction *showMenuBarAction)
0097 {
0098     Q_D(KHamburgerMenu);
0099     d->setShowMenuBarAction(showMenuBarAction);
0100 }
0101 
0102 void KHamburgerMenuPrivate::setShowMenuBarAction(QAction *showMenuBarAction)
0103 {
0104     m_showMenuBarAction = showMenuBarAction;
0105 }
0106 
0107 void KHamburgerMenu::addToMenu(QMenu *menu)
0108 {
0109     Q_D(KHamburgerMenu);
0110     d->insertIntoMenuBefore(menu, nullptr);
0111 }
0112 
0113 void KHamburgerMenu::insertIntoMenuBefore(QMenu *menu, QAction *before)
0114 {
0115     Q_D(KHamburgerMenu);
0116     d->insertIntoMenuBefore(menu, before);
0117 }
0118 
0119 void KHamburgerMenuPrivate::insertIntoMenuBefore(QMenu *menu, QAction *before)
0120 {
0121     Q_CHECK_PTR(menu);
0122     Q_Q(KHamburgerMenu);
0123     if (!m_menuAction) {
0124         m_menuAction = new QAction(this);
0125         m_menuAction->setText(i18nc("@action:inmenu General purpose menu", "&Menu"));
0126         m_menuAction->setIcon(q->icon());
0127         m_menuAction->setMenu(m_actualMenu.get());
0128     }
0129     updateVisibility(); // Sets the appropriate visibility of m_menuAction.
0130 
0131     menu->insertAction(before, m_menuAction);
0132     connect(menu, &QMenu::aboutToShow, this, [this, menu, q]() {
0133         if (m_menuAction->isVisible()) {
0134             Q_EMIT q->aboutToShowMenu();
0135             hideActionsOf(menu);
0136             resetMenu();
0137         }
0138     });
0139 }
0140 
0141 void KHamburgerMenu::hideActionsOf(QWidget *widget)
0142 {
0143     Q_D(KHamburgerMenu);
0144     d->hideActionsOf(widget);
0145 }
0146 
0147 void KHamburgerMenuPrivate::hideActionsOf(QWidget *widget)
0148 {
0149     Q_CHECK_PTR(widget);
0150     m_widgetsWithActionsToBeHidden.remove(nullptr);
0151     if (listContainsWidget(m_widgetsWithActionsToBeHidden, widget)) {
0152         return;
0153     }
0154     m_widgetsWithActionsToBeHidden.emplace_front(QPointer<const QWidget>(widget));
0155     if (QMenu *menu = qobject_cast<QMenu *>(widget)) {
0156         // QMenus are normally hidden. This will avoid redundancy with their actions anyways.
0157         menu->installEventFilter(m_listeners->get<AddOrRemoveActionListener>());
0158         notifyMenuResetNeeded();
0159     } else {
0160         // Only avoid redundancy when the widget is visible.
0161         widget->installEventFilter(m_listeners->get<VisibleActionsChangeListener>());
0162         if (widget->isVisible()) {
0163             notifyMenuResetNeeded();
0164         }
0165     }
0166 }
0167 
0168 void KHamburgerMenu::showActionsOf(QWidget *widget)
0169 {
0170     Q_D(KHamburgerMenu);
0171     d->showActionsOf(widget);
0172 }
0173 
0174 void KHamburgerMenuPrivate::showActionsOf(QWidget *widget)
0175 {
0176     Q_CHECK_PTR(widget);
0177     m_widgetsWithActionsToBeHidden.remove(widget);
0178     widget->removeEventFilter(m_listeners->get<AddOrRemoveActionListener>());
0179     widget->removeEventFilter(m_listeners->get<VisibleActionsChangeListener>());
0180     if (isWidgetActuallyVisible(widget)) {
0181         notifyMenuResetNeeded();
0182     }
0183 }
0184 
0185 QWidget *KHamburgerMenu::createWidget(QWidget *parent)
0186 {
0187     Q_D(KHamburgerMenu);
0188     return d->createWidget(parent);
0189 }
0190 
0191 QWidget *KHamburgerMenuPrivate::createWidget(QWidget *parent)
0192 {
0193     if (qobject_cast<QMenu *>(parent)) {
0194         qDebug(
0195             "Adding a KHamburgerMenu directly to a QMenu. "
0196             "This will look odd. Use addToMenu() instead.");
0197     }
0198     Q_Q(KHamburgerMenu);
0199 
0200     auto toolButton = new QToolButton(parent);
0201     // Set appearance
0202     toolButton->setDefaultAction(q);
0203     toolButton->setMenu(m_actualMenu.get());
0204     toolButton->setAttribute(Qt::WidgetAttribute::WA_CustomWhatsThis);
0205     toolButton->setPopupMode(QToolButton::InstantPopup);
0206     updateButtonStyle(toolButton);
0207     if (const QToolBar *toolbar = qobject_cast<QToolBar *>(parent)) {
0208         connect(toolbar, &QToolBar::toolButtonStyleChanged, toolButton, &QToolButton::setToolButtonStyle);
0209     }
0210 
0211     // Make sure the menu will be ready in time
0212     toolButton->installEventFilter(m_listeners->get<ButtonPressListener>());
0213 
0214     hideActionsOf(parent);
0215     return toolButton;
0216 }
0217 
0218 QAction *KHamburgerMenuPrivate::actionWithExclusivesFrom(QAction *from, QWidget *parent, std::unordered_set<const QAction *> &nonExclusives) const
0219 {
0220     Q_CHECK_PTR(from);
0221     if (nonExclusives.count(from) > 0) {
0222         return nullptr; // The action is non-exclusive/already visible elsewhere.
0223     }
0224     if (!from->menu() || from->menu()->isEmpty()) {
0225         return from; // The action is exclusive and doesn't have a menu.
0226     }
0227     std::unique_ptr<QAction> menuActionWithExclusives(new QAction(from->icon(), from->text(), parent));
0228     std::unique_ptr<QMenu> menuWithExclusives(new QMenu(parent));
0229     const auto fromMenuActions = from->menu()->actions();
0230     for (QAction *action : fromMenuActions) {
0231         QAction *actionWithExclusives = actionWithExclusivesFrom(action, menuWithExclusives.get(), nonExclusives);
0232         if (actionWithExclusives) {
0233             menuWithExclusives->addAction(actionWithExclusives);
0234         }
0235     }
0236     if (menuWithExclusives->isEmpty()) {
0237         return nullptr; // "from" has a menu that contains zero exclusive actions.
0238         // There is a chance that "from" is an exclusive action itself and should
0239         // therefore be returned instead but that is unlikely for an action that has a menu().
0240         // This fringe case is the only one that can't be correctly covered because we can
0241         // not know or assume that activating the action does something or if it is nothing
0242         // but a container for a menu.
0243     }
0244     menuActionWithExclusives->setMenu(menuWithExclusives.release());
0245     return menuActionWithExclusives.release();
0246 }
0247 
0248 std::unique_ptr<QMenu> KHamburgerMenuPrivate::newMenu()
0249 {
0250     std::unique_ptr<QMenu> menu(new QMenu());
0251     Q_Q(const KHamburgerMenu);
0252 
0253     // Make sure we notice if the q->menu() is changed or replaced in the future.
0254     if (q->menu() != m_lastUsedMenu) {
0255         q->menu()->installEventFilter(m_listeners->get<AddOrRemoveActionListener>());
0256 
0257         if (m_lastUsedMenu && !listContainsWidget(m_widgetsWithActionsToBeHidden, m_lastUsedMenu)) {
0258             m_lastUsedMenu->removeEventFilter(m_listeners->get<AddOrRemoveActionListener>());
0259         }
0260         m_lastUsedMenu = q->menu();
0261     }
0262 
0263     if (!q->menu() && !m_menuBar) {
0264         return menu; // empty menu
0265     }
0266 
0267     if (!q->menu()) {
0268         // We have nothing else to work with so let's just add the menuBar contents.
0269         const auto menuBarActions = m_menuBar->actions();
0270         for (QAction *menuAction : menuBarActions) {
0271             menu->addAction(menuAction);
0272         }
0273         return menu;
0274     }
0275 
0276     // Collect actions which shouldn't be added to the menu
0277     std::unordered_set<const QAction *> visibleActions;
0278     m_widgetsWithActionsToBeHidden.remove(nullptr);
0279     for (const QWidget *widget : m_widgetsWithActionsToBeHidden) {
0280         if (qobject_cast<const QMenu *>(widget) || isWidgetActuallyVisible(widget)) {
0281             // avoid redundancy with menus even when they are not actually visible.
0282             visibleActions.reserve(visibleActions.size() + widget->actions().size());
0283             const auto widgetActions = widget->actions();
0284             for (QAction *action : widgetActions) {
0285                 visibleActions.insert(action);
0286             }
0287         }
0288     }
0289     // Populate the menu
0290     const auto menuActions = q->menu()->actions();
0291     for (QAction *action : menuActions) {
0292         if (visibleActions.count(action) == 0) {
0293             menu->addAction(action);
0294             visibleActions.insert(action);
0295         }
0296     }
0297     // Add the last two menu actions
0298     if (m_menuBar) {
0299         connect(menu.get(), &QMenu::aboutToShow, this, [this]() {
0300             if (m_menuBar->actions().last()->icon().isNull()) {
0301                 m_helpIconIsSet = false;
0302                 m_menuBar->actions().last()->setIcon(QIcon::fromTheme(QStringLiteral("help-contents"))); // set "Help" menu icon
0303             } else {
0304                 m_helpIconIsSet = true; // if the "Help" icon was set by the application, we want to leave it untouched
0305             }
0306         });
0307         connect(menu.get(), &QMenu::aboutToHide, this, [this]() {
0308             if (m_menuBar->actions().last()->icon().name() == QStringLiteral("help-contents") && !m_helpIconIsSet) {
0309                 m_menuBar->actions().last()->setIcon(QIcon());
0310             }
0311         });
0312         menu->addAction(m_menuBar->actions().last()); // add "Help" menu
0313         visibleActions.insert(m_menuBar->actions().last());
0314         if (m_advertiseMenuBar) {
0315             menu->addSeparator();
0316             m_menuBarAdvertisementMenu = newMenuBarAdvertisementMenu(visibleActions);
0317             menu->addAction(m_menuBarAdvertisementMenu->menuAction());
0318         }
0319     }
0320     return menu;
0321 }
0322 
0323 std::unique_ptr<QMenu> KHamburgerMenuPrivate::newMenuBarAdvertisementMenu(std::unordered_set<const QAction *> &visibleActions)
0324 {
0325     std::unique_ptr<QMenu> advertiseMenuBarMenu(new QMenu());
0326     m_showMenuBarWithAllActionsText = i18nc("@action:inmenu A menu item that advertises and enables the menubar", "Show &Menubar with All Actions");
0327     connect(advertiseMenuBarMenu.get(), &QMenu::aboutToShow, this, [this]() {
0328         if (m_showMenuBarAction) {
0329             m_showMenuBarText = m_showMenuBarAction->text();
0330             m_showMenuBarAction->setText(m_showMenuBarWithAllActionsText);
0331         }
0332     });
0333     connect(advertiseMenuBarMenu.get(), &QMenu::aboutToHide, this, [this]() {
0334         if (m_showMenuBarAction && m_showMenuBarAction->text() == m_showMenuBarWithAllActionsText) {
0335             m_showMenuBarAction->setText(m_showMenuBarText);
0336         }
0337     });
0338     if (m_showMenuBarAction) {
0339         advertiseMenuBarMenu->addAction(m_showMenuBarAction);
0340         visibleActions.insert(m_showMenuBarAction);
0341     }
0342     QAction *section = advertiseMenuBarMenu->addSeparator();
0343 
0344     const auto menuBarActions = m_menuBar->actions();
0345     for (QAction *menuAction : menuBarActions) {
0346         QAction *menuActionWithExclusives = actionWithExclusivesFrom(menuAction, advertiseMenuBarMenu.get(), visibleActions);
0347         if (menuActionWithExclusives) {
0348             advertiseMenuBarMenu->addAction(menuActionWithExclusives);
0349         }
0350     }
0351     advertiseMenuBarMenu->setIcon(QIcon::fromTheme(QStringLiteral("view-more-symbolic")));
0352     advertiseMenuBarMenu->setTitle(i18nc("@action:inmenu A menu text advertising its contents (more features).", "More"));
0353     section->setText(i18nc("@action:inmenu A section heading advertising the contents of the menu bar", "More Actions"));
0354     return advertiseMenuBarMenu;
0355 }
0356 
0357 void KHamburgerMenuPrivate::resetMenu()
0358 {
0359     Q_Q(KHamburgerMenu);
0360     if (!m_menuResetNeeded && m_actualMenu && m_lastUsedMenu == q->menu()) {
0361         return;
0362     }
0363     m_menuResetNeeded = false;
0364 
0365     m_actualMenu = newMenu();
0366 
0367     const auto createdWidgets = q->createdWidgets();
0368     for (auto widget : createdWidgets) {
0369         static_cast<QToolButton *>(widget)->setMenu(m_actualMenu.get());
0370     }
0371     if (m_menuAction) {
0372         m_menuAction->setMenu(m_actualMenu.get());
0373     }
0374 }
0375 
0376 void KHamburgerMenuPrivate::updateVisibility()
0377 {
0378     Q_Q(KHamburgerMenu);
0379     /** The visibility of KHamburgerMenu should be opposite to the visibility of m_menuBar.
0380      * Exception: We only consider a visible m_menuBar as actually visible if it is not a native
0381      * menu bar because native menu bars can come in many shapes and sizes which don't necessarily
0382      * have the same usability benefits as a traditional in-window menu bar.
0383      * KDE applications normally allow the user to remove any actions from their toolbar(s) anyway. */
0384     const bool menuBarVisible = m_menuBar && (m_menuBar->isVisible() && !m_menuBar->isNativeMenuBar());
0385     q->setVisible(!menuBarVisible);
0386 
0387     if (!m_menuAction) {
0388         if (menuBarVisible && m_actualMenu) {
0389             m_actualMenu.release()->deleteLater(); // might as well free up some memory
0390         }
0391         return;
0392     }
0393 
0394     const auto createdWidgets = q->createdWidgets();
0395     // The m_menuAction acts as a fallback if both the m_menuBar and all createdWidgets() on the UI
0396     // are currently hidden. Only then should the m_menuAction ever be visible in a QMenu.
0397     if (menuBarVisible || (m_menuBar && m_menuBar->isNativeMenuBar()) // See [1] below.
0398         || std::any_of(createdWidgets.cbegin(), createdWidgets.cend(), isWidgetActuallyVisible)) {
0399         m_menuAction->setVisible(false);
0400         return;
0401     }
0402     m_menuAction->setVisible(true);
0403 
0404     // [1] While the m_menuAction can be used as a normal menu by users that don't mind invoking a
0405     //     QMenu to access any menu actions, its primary use really is that of a fallback.
0406     //     Therefore the existence of a native menu bar (no matter what shape or size it might have)
0407     //     is enough reason for us to hide m_menuAction.
0408 }
0409 
0410 void KHamburgerMenuPrivate::slotActionChanged()
0411 {
0412     Q_Q(KHamburgerMenu);
0413     const auto createdWidgets = q->createdWidgets();
0414     for (auto widget : createdWidgets) {
0415         auto toolButton = static_cast<QToolButton *>(widget);
0416         updateButtonStyle(toolButton);
0417     }
0418 }
0419 
0420 void KHamburgerMenuPrivate::updateButtonStyle(QToolButton *hamburgerMenuButton) const
0421 {
0422     Q_Q(const KHamburgerMenu);
0423     Qt::ToolButtonStyle buttonStyle = Qt::ToolButtonFollowStyle;
0424     if (QToolBar *toolbar = qobject_cast<QToolBar *>(hamburgerMenuButton->parent())) {
0425         buttonStyle = toolbar->toolButtonStyle();
0426     }
0427     if (buttonStyle == Qt::ToolButtonFollowStyle) {
0428         buttonStyle = static_cast<Qt::ToolButtonStyle>(hamburgerMenuButton->style()->styleHint(QStyle::SH_ToolButtonStyle));
0429     }
0430     if (buttonStyle == Qt::ToolButtonTextBesideIcon && q->priority() < QAction::NormalPriority) {
0431         hamburgerMenuButton->setToolButtonStyle(Qt::ToolButtonIconOnly);
0432     } else {
0433         hamburgerMenuButton->setToolButtonStyle(buttonStyle);
0434     }
0435 }
0436 
0437 #include "moc_khamburgermenu.cpp"
0438 #include "moc_khamburgermenu_p.cpp"