1

I write a PySide6 application where I have a layout with three QListView widgets next to each other. Each displays something with a different list model, and all shall have a context menu. What doesn't work is that the right list model or list view gets resolved. This leads to the context menu appearing in the wrong location, and also the context menu actions working on the wrong thing.

I have done a right-click into the circled area, the context menu shows up in the right panel:

Screenshot of PySide6 application

This is the first iteration of an internal tool, so it is not polished, and neither did I separate controller and view of the UI. This is a minimal full example:

import sys
from typing import Any
from typing import List
from typing import Union

from PySide6.QtCore import QAbstractItemModel
from PySide6.QtCore import QAbstractListModel
from PySide6.QtCore import QModelIndex
from PySide6.QtCore import QPersistentModelIndex
from PySide6.QtCore import QPoint
from PySide6.QtGui import Qt
from PySide6.QtWidgets import QApplication
from PySide6.QtWidgets import QHBoxLayout
from PySide6.QtWidgets import QListView
from PySide6.QtWidgets import QListWidget
from PySide6.QtWidgets import QMainWindow
from PySide6.QtWidgets import QMenu
from PySide6.QtWidgets import QPushButton
from PySide6.QtWidgets import QVBoxLayout
from PySide6.QtWidgets import QWidget


class PostListModel(QAbstractListModel):
    def __init__(self, full_list: List[str], prefix: str):
        super().__init__()
        self.full_list = full_list
        self.prefix = prefix
        self.endResetModel()

    def endResetModel(self) -> None:
        super().endResetModel()
        self.my_list = [
            element for element in self.full_list if element.startswith(self.prefix)
        ]

    def rowCount(self, parent=None) -> int:
        return len(self.my_list)

    def data(
        self, index: Union[QModelIndex, QPersistentModelIndex], role: int = None
    ) -> Any:
        if role == Qt.DisplayRole or role == Qt.ItemDataRole:
            return self.my_list[index.row()]


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.resize(1200, 500)

        prefixes = ["Left", "Center", "Right"]
        self.full_list = [f"{prefix} {i}" for i in range(10) for prefix in prefixes]

        central_widget = QWidget(parent=self)
        self.setCentralWidget(central_widget)
        main_layout = QVBoxLayout(parent=central_widget)
        central_widget.setLayout(main_layout)
        columns_layout = QHBoxLayout(parent=main_layout)
        main_layout.addLayout(columns_layout)

        self.list_models = {}
        self.list_views = {}
        for prefix in prefixes:
            list_view = QListView(parent=central_widget)
            list_model = PostListModel(self.full_list, prefix)
            self.list_models[prefix] = list_model
            self.list_views[prefix] = list_view
            list_view.setModel(list_model)
            list_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
            list_view.customContextMenuRequested.connect(
                lambda pos: self.show_context_menu(list_view, pos)
            )
            columns_layout.addWidget(list_view)
            print("Created:", list_view)

    def show_context_menu(self, list_view, pos: QPoint):
        print("Context menu on:", list_view)
        global_pos = list_view.mapToGlobal(pos)
        index_in_model = list_view.indexAt(pos).row()
        element = list_view.model().my_list[index_in_model]

        menu = QMenu()
        menu.addAction("Edit", lambda: print(element))
        menu.exec(global_pos)


def main():
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    retval = app.exec()
    sys.exit(retval)


if __name__ == "__main__":
    main()

When it is run like that, it gives this output:

Created: <PySide6.QtWidgets.QListView(0x55f2dcc5c980) at 0x7f1d7cc9d780>
Created: <PySide6.QtWidgets.QListView(0x55f2dcc64e10) at 0x7f1d7cc9da40>
Created: <PySide6.QtWidgets.QListView(0x55f2dcc6bbe0) at 0x7f1d7cc9dd00>
Context menu on: <PySide6.QtWidgets.QListView(0x55f2dcc6bbe0) at 0x7f1d7cc9dd00>

I think that the issue is somewhere in the binding of the lambda to the signal. It seems to always take the last value. Since I re-assign list_view, I don't think that the reference within the lambda would change.

Something is wrong with the references, but I cannot see it. Do you see why the lambda connected to the context menu signal always has the last list view as context?

Martin Ueding
  • 8,245
  • 6
  • 46
  • 92
  • 2
    how about reducing this to a [Minimal, Reproducible Example](https://stackoverflow.com/help/minimal-reproducible-example) – Thomas Jul 16 '22 at 20:15
  • 1
    Can you add the imports? with qt for python it is really hard to reproduce without imports – ניר Jul 16 '22 at 20:18
  • Your mapToGlobal uses the wrong widget - please see the [documentation](https://doc.qt.io/qt-6/qwidget.html#customContextMenuRequested) and use the viewport of the listview. – chehrlic Jul 16 '22 at 20:36
  • @Thomas: Sure, should have done that in the first place. I was assuming that it was possible to see, but that's not how debugging works. ☺️ – Martin Ueding Jul 17 '22 at 06:08
  • @chehrlic: I've updated the output and include the memory addresses of the QListView instances that are created and the one that is used with `mapToGlobal`. So it is the wrong list view, but why does it come out wrong? – Martin Ueding Jul 17 '22 at 06:09
  • Because of e.g. https://stackoverflow.com/questions/7546285/creating-lambda-inside-a-loop and your mapToGlobal must use the viewport of the listview, not the listview itself as I already told you and is written in the documentation. – chehrlic Jul 17 '22 at 07:48

1 Answers1

2

This is happening because lambdas are not immutable objects. All non parameter variables used inside of a lambda will update whenever that variable updates.

So when you connect to the slot:

lambda pos: self.show_context_menu(list_view, pos)

On each iteration of your for loop you are setting the current value of the list_view variable as the first argument of the show_context_menu method. But when the list_view changes for the second and proceeding iterations, it is also updating for all the preceding iterations.

Here is a really simple example:

names = ["alice", "bob", "chris"]

funcs = []

for name in names:
    funcs.append(lambda: print(f"Hello {name}"))

for func in funcs:
    func()

Output:

Hello chris
Hello chris
Hello chris
Alexander
  • 16,091
  • 5
  • 13
  • 29
  • Oh wow, I didn't realize that the lambda did not capture the current value from the scope. I have reorganzied the code, and now this isn't a problem any more. Thanks a lot! – Martin Ueding Jul 17 '22 at 14:34