简体   繁体   中英

Python PyQt6 contextMenuEvent strange behavior I don't understand

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?

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;

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).

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM