Prevent a QMenu from closing when one of its QAction is triggered
Asked Answered
P

10

19

I'm using a QMenu as context menu. This menu is filled with QActions. One of these QActions is checkable, and I'd like to be able to check/uncheck it without closing the context menu (and having to re-open it again to choose the option that I want).

I've tried disconnecting the signals emitted by the checkable QAction with no luck.

Any ideas? Thanks.

Pellicle answered 12/1, 2010 at 16:27 Comment(0)
A
29

Use a QWidgetAction and QCheckBox for a "checkable action" which doesn't cause the menu to close.

QCheckBox *checkBox = new QCheckBox(menu);
QWidgetAction *checkableAction = new QWidgetAction(menu);
checkableAction->setDefaultWidget(checkBox);
menu->addAction(checkableAction);

In some styles, this won't appear exactly the same as a checkable action. For example, for the Plastique style, the check box needs to be indented a bit.

Antalkali answered 13/1, 2010 at 6:32 Comment(5)
Thanks a lot. With the plastique style there's indeed a margin to add. So I put the checkbox in a widget with a layout, and set it margins (maybe there's a simpler way...) One last thing: the checkbox doesn't expands to the full width of the menu, so if the click occurs after the end of the box's label the menu is closed and the box is not checked. Setting the size policy has no effect.Pellicle
This doesn't work with QsystemTrayIcon.contextMenu() on Ubuntu Unity as Unity doesn't show Widgets from inside QWidgetActionArchon
@Pellicle is there a way to expand the checkbox to the full width of the menu?Ranique
This has various issues. 1. No proper padding/margin. 2. No highlighting when you hoover over the QToolButton QMenu actions, etc. I do not think this is a solution. There must be a better way.Oilcan
This still does not work with QSystemTrayIcon, using Qt 6.5, KDE Neon which is based on Ubuntu.Carbide
B
14

There doesn't seem to be any elegant way to prevent the menu from closing. However, the menu will only close if the action can actually trigger, i.e. it is enabled. So, the most elegant solution I found is to trick the menu by shortly disabling the action at the moment when it would be triggered.

  1. Subclass QMenu
  2. Reimplement relevant event handlers (like mouseReleaseEvent())
  3. In the event handler, disable the action, then call base class' implementation, then enable the action again, and trigger it manually

This is an example of reimplemented mouseReleaseEvent():

void mouseReleaseEvent(QMouseEvent *e)
{
    QAction *action = activeAction();
    if (action && action->isEnabled()) {
        action->setEnabled(false);
        QMenu::mouseReleaseEvent(e);
        action->setEnabled(true);
        action->trigger();
    }
    else
        QMenu::mouseReleaseEvent(e);
}

To make the solution perfect, similar should be done in all event handlers that may trigger the action, like keyPressEvent(), etc...

The trouble is that it is not always easy to know whether your reimplementation should actually trigger the action, or even which action should be triggered. The most difficult is probably action triggering by mnemonics: you would need to reimplement the complex algorithm in QMenu::keyPressEvent() yourself.

Backbencher answered 19/2, 2013 at 20:54 Comment(4)
This is exactly the same solution that I just came up with, and it works well as far as I can tell. Guess I should have read all the answers here before experimenting on my own.Geneticist
Instead of disabling&enabling the action, you can also not call QMenu::mouseReleaseEvent. In which case it works like a charm. Overriding keyPressEvent and adding behavior for the space button worked fine as well.Heavyweight
The issue with this approach is that when the menu doesn't fit on the screen, there are arrows on the top/bottom of the menu to scroll up/down. Once you trigger an action, the menu turns to appear with its total height (without the scrolls), and therefore, you cannot see the entire menu.Gingery
I feel like there should be a property on QMenu for this instead of subclassing and reinventing the wheel.Oilcan
I
3

I have been struggling with this for half a day.

There was many accepted answers on the net suggesting overriding setVisible function of the QMenu which did not work for me at all. I found a solution based on this post (The last reply by the OP)

my C++ implementation for this matter is as follows:

bool MainWindow::eventFilter(QObject *watched, QEvent *event)
{
    if (event->type() == QEvent::MouseButtonRelease)
    {
        auto action = static_cast<QMenu*>(watched)->activeAction();
        if (action && action->isCheckable())
        {
            action->trigger();
            return true;
        }
    }
    return QObject::eventFilter(watched, event);
}
Induce answered 29/1, 2023 at 12:57 Comment(0)
A
2

(I started with Andy's answer, so thank you Andy!)

1) aboutToHide() works, by re-popping the menu at a cached position, BUT it can also enter an infinite loop. Testing if the mouse is clicked outside the menu to ignore re-opening should do the trick.

2) I tried an event filter but it blocks the actual click to the menu item.

3) Use both.

Here is a dirty pattern to prove that it works. This keeps the menu open when the user holds down CTRL when clicking:

    # in __init__ ...
    self.options_button.installEventFilter(self)
    self.options_menu.installEventFilter(self)
    self.options_menu.aboutToHide.connect(self.onAboutToHideOptionsMenu)

    self.__options_menu_pos_cache = None
    self.__options_menu_open = False

def onAboutToHideOptionsMenu(self):
    if self.__options_menu_open:          # Option + avoid an infinite loop
        self.__options_menu_open = False  # Turn it off to "reset"
        self.options_menu.popup(self.__options_menu_pos_cache)

def eventFilter(self, obj, event):
    if event.type() == QtCore.QEvent.MouseButtonRelease:
        if obj is self.options_menu:
            if event.modifiers() == QtCore.Qt.ControlModifier:
                self.__options_menu_open = True

            return False

        self.__options_menu_pos_cache = event.globalPos()
        self.options_menu.popup(event.globalPos())
        return True

    return False

I say it is dirty because the widget here is acting as an event filter for both the button that opens the menu as well as the menu itself. Using explicit event filter classes would be easy enough to add and it would make things a little easier to follow.

The bools could probably be replaced with a check to see if the mouse is over the menu, and if not, don't pop it open. However, the CTRL key still has to be factored in for my use case, so it probably isn't far off a nice solution as it is.

When the user holds down CTRL and clicks on the menu, it flips a switch so the menu opens itself back up when it tried to close. The position is cached so it opens at the same position. There is a quick flicker, but it feels OK since the user knows they are holding a key down to make this work.

At the end of the day (literally) I already had the whole menu doing the right thing. I just wanted to add this functionality and I definitely didn't want to change to using a widget just for this. For this reason, I am keeping even this dirty patch for now.

Appointment answered 20/7, 2012 at 1:12 Comment(0)
P
2

This is my solution:

    // this menu don't hide, if action in actions_with_showed_menu is chosen.
    class showed_menu : public QMenu
    {
      Q_OBJECT
    public:
      showed_menu (QWidget *parent = 0) : QMenu (parent) { is_ignore_hide = false; }
      showed_menu (const QString &title, QWidget *parent = 0) : QMenu (title, parent) { is_ignore_hide = false; }
      void add_action_with_showed_menu (const QAction *action) { actions_with_showed_menu.insert (action); }

      virtual void setVisible (bool visible)
      {
        if (is_ignore_hide)
          {
            is_ignore_hide = false;
            return;
          }
        QMenu::setVisible (visible);
      }

      virtual void mouseReleaseEvent (QMouseEvent *e)
      {
        const QAction *action = actionAt (e->pos ());
        if (action)
          if (actions_with_showed_menu.contains (action))
            is_ignore_hide = true;
        QMenu::mouseReleaseEvent (e);
      }
    private:
      // clicking on this actions don't close menu 
      QSet <const QAction *> actions_with_showed_menu;
      bool is_ignore_hide;
    };

    showed_menu *menu = new showed_menu ();
    QAction *action = menu->addAction (new QAction (menu));
    menu->add_action_with_showed_menu (action);
Purington answered 12/9, 2012 at 17:24 Comment(0)
D
1

Here are couple ideas I've had... Not sure at all they will work tho ;)

1) Try to catch the Event by using the QMenu's method aboutToHide(); Maybe you can "Cancel" the hide process ?

2) Maybe you could consider using an EventFilter ?

Try to have a look at : http://qt.nokia.com/doc/4.6/qobject.html#installEventFilter

3) Otherwise you could reimplement QMenu to add your own behavior, but it seems a lot of work to me...

Hope this helps a bit !

Dachshund answered 12/1, 2010 at 21:57 Comment(0)
I
1

Connect the QMenu.show to the action trigger. I know this is code for Qt5 (and in Python), but the principle should be back compatible.

from PyQt5 import QtWidgets

class CheckableMenu(QtWidgets.QMenuBar):
    
    def __init__(self,parent=None):
        super().__init__(parent)
        
        self.menuObj=QtWidgets.QMenu("View")
        self.addMenu(self.menuObj)
        for i in ['Both','Even','Odd']: #my checkable items
            t=QtWidgets.QAction(i,self.menuObj,checkable=True)
            t.triggered.connect(self.menuObj.show)
            self.menuObj.addAction(t)
Incautious answered 28/10, 2022 at 16:39 Comment(0)
H
0

Using this answer, the checkbox didn't work as I was expecting, because I was connecting to the slot triggered(), rather than connecting to the checkbox's toggled(bool).

I'm using the below code to open a menu with several checkboxes when I press a button:

QMenu menu;

QCheckBox *checkBox = new QCheckBox("Show Grass", &menu);
checkBox->setChecked(m_showGrass);

QWidgetAction *action = new QWidgetAction(&menu);
action->setDefaultWidget(checkBox);

menu.addAction(action);
//connect(action, SIGNAL(triggered()), this, SLOT(ToggleShowHardscape_Grass()));
connect(checkBox, SIGNAL(toggled(bool)), this, SLOT(ToggleShowHardscape_Grass()));

menu.exec(QCursor::pos() + QPoint(-300, 20));
Hobnail answered 27/8, 2019 at 10:31 Comment(0)
T
0

Subclass QMenu and override setVisible. You can utilize activeAction() to know if an action was selected and the visible arg to see if the QMenu is trying to close, then you can override and call QMenu::setVisible(...) with the value you want.

class ComponentMenu : public QMenu
{
public:
    using QMenu::QMenu;

    void setVisible(bool visible) override
    {
        // Don't hide the menu when holding Shift down
        if (!visible && activeAction())
            if (QApplication::queryKeyboardModifiers().testFlag(Qt::ShiftModifier))
                return;

        QMenu::setVisible(visible);
    }
};
Taperecord answered 1/5, 2021 at 0:26 Comment(0)
S
-1

Could be done like this:

menuSettings = menubar.addMenu('&Settings')
selection_debug = QAction('Debug', self, checkable=True)
selection_debug.triggered.connect(lambda : menuSettings.show())

menuSettings.addAction(selection_debug)
Steamy answered 15/9, 2023 at 19:38 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.