0

I have the following code giving me trouble:

class TableView(qt.QTableView):
    def __init__(self, param):
        super().__init__()
        self.model = param.model
        self.view = self
        self.mains = [QAction('Remove row'), QAction('Split expense')]
        self.types = [QAction('Bills'), QAction('Vapors')]

    def contextMenuEvent(self, event):
        row = self.view.rowAt(event.y())
        col = self.view.columnAt(event.x())
        main_menu = qt.QMenu()
        type_menu = qt.QMenu('Update')
        main_menu.addActions(self.mains)
        type_menu.addActions(self.types)
        if col == 1:
            main_menu.addMenu(type_menu)
        #mains[0].triggered.connect(lambda: self.remove_row(row))
        for e in self.types:
            print(e)
            e.triggered.connect(lambda: self.update_type(row, e))
        main_menu.exec(QCursor.pos())

    def remove_row(self, row):
        self.model.removeRow(row)

    def update_type(self, row, action):
        print(action)

It should update print the correct QAction based on the chosen context menu. The loop returns...

<PyQt6.QtGui.QAction object at 0x7f77fd619480>
<PyQt6.QtGui.QAction object at 0x7f77fd619510>

...every time. <PyQt6.QtGui.QAction object at 0x7f77fd619480> should be tied to "Bills" and <PyQt6.QtGui.QAction object at 0x7f77fd619510> should be tied to "Vapors". When I run it, no matter what menu option I choose, it returns <PyQt6.QtGui.QAction object at 0x7f77fd619510>. To make matters worse, right-clicking should print the loop once, followed by the menu selection (which is always <PyQt6.QtGui.QAction object at 0x7f77fd619510>), but what happens after the first row in the table gets right-clicked, is <PyQt6.QtGui.QAction object at 0x7f77fd619510> is printed twice. What gives?

EDIT

Okay, I managed to fix part of the problem with the help of other posts.

for e in self.types:
            e.triggered.connect(lambda d, e=e: self.update_type(row, e))

But I still have a problem. The signal fires each the number of times I press a context menu item per time the GUI is open. So, I launch the GUI, right-click and select some thing and it fires once. Then I right-click again and it fores twice, then three times and so on for the number of times I right-clicked. Why?

malonn
  • 59
  • 1
  • 7

1 Answers1

2

There are two main problems with your code:

  1. variables inside lambdas are evaluated at execution, so e always corresponds to the last reference assigned in the loop;
  2. when a signal is emitted, functions are called as many times they have been connected: each time you create the menu, you're connecting the signal once again;

Depending on the situations, there are many ways to achieve what you need. Here are some possible options:

Compare the triggered action returned by exec()

QMenu.exec() always returns the action that has been triggered, knowing that you can just compare it and eventually decide what to do:

class TableView(QTableView):
    def __init__(self, param):
        super().__init__()
        self.setModel(param.model)
        self.mains = [QAction('Remove row'), QAction('Split expense')]
        self.types = [QAction('Bills'), QAction('Vapors')]

    def contextMenuEvent(self, event):
        index = self.indexAt(event.pos())
        main_menu = QMenu()
        for action in self.mains:
            main_menu.addAction(action)
            action.setEnabled(index.isValid())

        if index.column() == 1:
            type_menu = main_menu.addMenu('Update')
            type_menu.addActions(self.types)

        action = main_menu.exec(event.globalPos())
        if action in self.mains:
            if action == self.mains[0]:
                self.remove_row(index.row())
        elif action in self.types:
            self.update_type(index.row(), action)

    def remove_row(self, row):
        self.model().removeRow(row)

    def update_type(self, row, action):
        print(action)

Use the action.data() as argument

QActions supports setting arbitrary data, so we can set that data to the row. If we are using the action.triggered signal, we can retrieve the action through self.sender() (which returns the object that emitted the signal). Otherwise, we can use menu.triggered() to call the target function with the action that has triggered it as argument.

class TableView(QTableView):
    def __init__(self, param):
        super().__init__()
        self.setModel(param.model)
        self.mains = [QAction('Remove row'), QAction('Split expense')]
        self.mains[0].triggered.connect(self.remove_row)
        self.types = [QAction('Bills'), QAction('Vapors')]

    def contextMenuEvent(self, event):
        index = self.indexAt(event.pos())
        main_menu = QMenu()
        for action in self.mains:
            main_menu.addAction(action)
            action.setEnabled(index.isValid())
            action.setData(index.row())
        if index.column() == 1:
            type_menu = main_menu.addMenu('Update')
            type_menu.triggered.connect(self.update_type)
            for action in self.types:
                type_menu.addAction(action)
                action.setData(index.row())

        main_menu.exec(event.globalPos())

    def remove_row(self):
        sender = self.sender()
        if isinstance(sender, QAction):
            row = sender.data()
            if row is not None:
                self.model().removeRow(row)

    def update_type(self, action):
        print(action, action.data())

So, no lambda?

Lambdas can certainly be used, but considering what explained above, and that your requirement is to use dynamic arguments, that can be tricky.

You can use it for a fully dynamical menu (including creation of actions), otherwise you'd need to always try to disconnect() the signal, and that might be tricky:

  • using a lambda as target slot means that you don't have any previous reference to the function that has to be disconnected;
  • completely disconnecting the signal (using the generic signal.disconnect()) might not be a good choice, if the signal was previously connected to other functions;

A fully dynamical menu

The above solutions are based on the fact that the actions already existed at the time of the context menu event.

This is usually not a requirement. In fact, many widgets in Qt always create a brand new menu along with its actions. This is the case of all text-based widgets (QLineEdit, QTextEdit and even QLabels with the proper text interaction flags): the menu is always temporary.

With this in mind, we can take an approach based on what explained above, but without thinking about "changing" or "restoring" previous states, data or connections: the menu will be destroyed as soon as it's closed, along with any of its actions (since they've been created as children of the menu), so Python and Qt will take care of releasing resources that are not needed anymore.

While this continuous creation/destroy of objects might not seem optimal, memory/performance wise, it's actually conceptually better and quite effective: menus don't usually need extreme performance, and creating/destroying them is actually simpler than managing the behavior of a persistent set of menu/actions depending on the context.

class TableView(QTableView):
    def __init__(self, param):
        super().__init__()
        self.setModel(param.model)

    def contextMenuEvent(self, event):
        index = self.indexAt(event.pos())
        isValid = index.isValid()
        main_menu = QMenu()

        removeAction = main_menu.addAction('Remove row')
        if isValid:
            removeAction.triggered.connect(lambda:
                self.remove_row(index.row()))
        else:
            removeAction.setEnabled(False)

        splitAction = main_menu.addAction('Split expanse')
        if isValid:
            splitAction.triggered.connect(lambda:
                self.split_expanse(index.row()))
        else:
            splitAction.setEnabled(False)

        type_menu = main_menu.addMenu('Update')
        if index.column() != 1:
            type_menu.setEnabled(False)
        else:
            billsAction = type_menu.addAction('Bills')
            billsAction.triggered.connect(lambda:
                self.bills(index.row()))
            vaporsAction = type_menu.addAction('Vapors')
            vaporsAction.triggered.connect(lambda:
                self.doVapors(index.row()))

        main_menu.exec(event.globalPos())

Further options

There are occasions for which keeping persistent actions or menus is required, for instance a menu that has lots of items that require some amount of time to be created.

As already explained, signals can be connected to multiple functions at the same time (and even the same function more than once).

The issue with lambdas is that we usually use them "in line". Doing this, we always lose the reference to their connection:

    self.someAction.triggered.connect(lambda: self.whatever(xyz))

While we could just use the generic signal.disconnect(), which disconnects the signal from any function or slot connected to it, that might not be a viable option: maybe the signal is also connected to some other function that is always required to be triggered, no matter of the context (such as a visual hint about the activation of actions). This means that we cannot specifically disconnect from a lambda used as above.

Luckily, as we know, in Python "everything is an object", including lambdas:

    doWhatever = lambda: self.whatever(xyz)
    self.someAction.triggered.connect(doWhatever)
    # ...
    menu.exec(pos)
    self.someAction.triggered.disconnect(doWhatever)

In this way, we ensure that we only connect to the action in the context of the menu event, and disconnect it afterwards, no matter of the actual action that has been triggered.

Note that the above is actually the same as using a local function (which is what lambdas are, conceptually speaking):

    def doWhatever():
        self.whatever(xyz)

    self.someAction.triggered.connect(doWhatever)
    # ...
    menu.exec(pos)
    self.someAction.triggered.disconnect(doWhatever)

The benefit of the above approach is that a local function can be extended more easily than a simple lambda.

Conclusions

QAction is quite a strange class. It's not a widget, but it can be used for that purpose, it doesn't need a parent, and can be shared between many objects (menus, toolbars, etc.). As opposite to widgets, an action can appear in many places at the same time even in the same UI: a tool bar, a menubar, context menu, a QToolButton.

Nonetheless, setting the parent of a new action doesn't automatically add the action to that parent list of actions, so someObject.actions() won't list that action unless addAction() has been explicitly called.

The "migration" of Qt6 from QtWidgets to QtGui made these aspect partially more clear, but it can still create confusion.

Due to their "abstract" nature (and considering the above aspects), you can trigger an action in many ways, and a triggered action can call connected slots in unexpected ways if the whole QAction concept is clear to the developer.

It's extremely important to understand all that, as the implementation of their "triggering" might change dramatically, and awareness of those aspects is mandatory to properly implement their usage.

For instance, using a list that groups actions might not be the proper choice, and you may consider QActionGroup instead (no matter if the actions are checkable or the group is exclusive).

musicamante
  • 41,230
  • 6
  • 33
  • 58
  • I'll start with bad form: thank you, @musicamante. You're a Qt machine and immensely helpful. Next, a matter of your opinion, being these concept are all new to me. Either suggestion you provided works (of course), but which one would you go with? Obviously, I'm about as new as one can come to Qt, and I don't see any obvious pros and cons to either option from a Qt perspective. I'll probably just choose whatever leads to fewer lines of code. – malonn Jul 01 '22 at 09:10
  • I may bake my own solution. Instead of connecting the slot at each right-click, I may connect during initialization of the class. Other than that, I like the first option more. – malonn Jul 01 '22 at 09:30
  • @malonn As said in the last part of my answer, there is no absolute correct/good/proper way: due to the "strange" nature of QActions, every case has its own solution, and no one is "the best": there might be *better* or *worse* approaches, but requirements might change and [d]evolve. And you're right: when I finalized the answer, I forgot that I also wanted to add an explanation about connecting signals in the `__init__`, which is quite common indeed. But, as said, it all depends: dynamic function calls might require dynamically created actions. For instance, all text based widgets that -> – musicamante Jul 02 '22 at 05:47
  • -> support context menus (QLabel in interactive mode, QTextEdit, etc.) ***always*** create a completely new menu with new actions that are connected to the class slots based on the context of their creation. Consider that menus don't (usually) require extreme performance, creating temporary new menus, actions and connections every time they're shown is not a bad thing per-se. As soon as possible I will expand my answer to add those details. – musicamante Jul 02 '22 at 05:51
  • Alright, @musicamante. I look forward to your expansion of the answer to get a better understanding of what you mean. For now, I connected the slots in the `__init__` function. All is working as expected. – malonn Jul 03 '22 at 10:24
  • @malonn I finally had time to expand my answer, I hope it makes things more clear. – musicamante Jul 08 '22 at 00:59