pythonpyqtqabstracttablemodelqdatawidgetmapper

Alerting QDataWidgetMapper to changes when using a custom Model & Delegate


I'm using a subclassed QAbstractTableModel with dataclasses as items. Each dataclass contains a field "field1" with a list, which I'd like to display in a listview and have it automatically change whenever I edit or add an item in the listview.

To do that I set a custom delegate to the QDataWidgetMapper which will retrieve and set the values from that dataclass. This works the way I want it to.

My problem is that I want to add additional items to that listview with the press of a button and have the QDataWidgetMapper add them automatically to the model.

This is what I have so far:

ListView with three items and a QPushButton underneath

import sys
import dataclasses
from typing import List, Any
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *


@dataclasses.dataclass()
class StorageItem:

    field1: List[str] = dataclasses.field(default_factory=list)


class StorageModel(QAbstractTableModel):

    def __init__(self, parent=None):
        super().__init__(parent)

        test = StorageItem()
        test.field1 = ['Item °1', 'Item °2']
        self._data: List[StorageItem] = [test]

    def data(self, index: QModelIndex, role: int = ...) -> Any:
        if not index.isValid():
            return

        item = self._data[index.row()]
        col = index.column()

        if role in {Qt.DisplayRole, Qt.EditRole}:
            if col == 0:
                return item.field1
            else:
                return None

    def setData(self, index: QModelIndex, value, role: int = ...) -> bool:

        if not index.isValid() or role != Qt.EditRole:
            return False

        item = self._data[index.row()]
        col = index.column()

        if col == 0:
            item.field1 = value

        self.dataChanged.emit(index, index)
        print(self._data)
        return True

    def flags(self, index: QModelIndex) -> Qt.ItemFlags:
        return Qt.ItemFlags(
            Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable
        )

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

    def columnCount(self, parent=None) -> int:
        return len(dataclasses.fields(StorageItem))


class TestDelegate(QStyledItemDelegate):

    def __init__(self, parent=None):
        super().__init__(parent)

    def setEditorData(self, editor: QWidget, index: QModelIndex) -> None:
        if isinstance(editor, QListView):
            data = index.model().data(index, Qt.DisplayRole)
            editor.model().setStringList(data)
        else:
            super().setEditorData(editor, index)

    def setModelData(
            self, editor: QWidget,
            model: QAbstractItemModel,
            index: QModelIndex
    ) -> None:

        if isinstance(editor, QListView):
            data = editor.model().stringList()
            model.setData(index, data, Qt.EditRole)
        else:
            super().setModelData(editor, model, index)


class CustomListView(QListView):

    item_added = pyqtSignal(name='itemAdded')

    def __init__(self, parent=None):
        super().__init__(parent)

        self.setModel(QStringListModel())

    def add_item(self, item: str):
        str_list = self.model().stringList()
        str_list.append(item)
        self.model().setStringList(str_list)
        self.item_added.emit()


class MainWindow(QMainWindow):

    def __init__(self, parent=None):
        super().__init__(parent)

        cent_widget = QWidget()
        self.setCentralWidget(cent_widget)

        # Vertical Layout
        v_layout = QVBoxLayout()
        v_layout.setContentsMargins(10, 10, 10, 10)

        # Listview
        self.listview = CustomListView()
        v_layout.addWidget(self.listview)

        # Button
        self.btn = QPushButton('Add')
        self.btn.clicked.connect(lambda: self.listview.add_item('New Item'))
        v_layout.addWidget(self.btn)

        cent_widget.setLayout(v_layout)

        # Set Mapping
        self.mapper = QDataWidgetMapper()
        self.mapper.setItemDelegate(TestDelegate())
        self.mapper.setSubmitPolicy(QDataWidgetMapper.AutoSubmit)
        self.mapper.setModel(StorageModel())
        self.mapper.addMapping(self.listview, 0)
        self.mapper.toFirst()

        self.listview.itemAdded.connect(self.mapper.submit)


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


if __name__ == '__main__':
    main()

Currently, I'm using the signal itemAdded from inside the custom ListView to manually submit the QDataWidgetMapper.

Is there a way to do this within CustomListView, without using a custom signal? Somehow the delegate knows when data in the listview has been edited. How can I trigger that same mechanism when new items are added?


Solution

  • TL; DR; It can not.


    The submitPolicy QDataWidgetMapper::AutoSubmit indicates that the model will be updated when focus is lost. The model is also updated when the commitData or closeEditor signal of the delegate is invoked, which happens by default when some specific keys are pressed.

    A better implementation would be to create a signal that is emitted every time a change is made in the QListView model and connect it to submit, not just the method of adding elements. Also it is better to use a custom qproperty.

    class CustomListView(QListView):
        items_changed = pyqtSignal(name="itemsChanged")
    
        def __init__(self, parent=None):
            super().__init__(parent)
    
            self.setModel(QStringListModel())
            self.model().rowsInserted.connect(self.items_changed)
            self.model().rowsRemoved.connect(self.items_changed)
            self.model().dataChanged.connect(self.items_changed)
            self.model().layoutChanged.connect(self.items_changed)
    
        def add_item(self, item: str):
            self.items += [item]
    
        @pyqtProperty(list, notify=items_changed)
        def items(self):
            return self.model().stringList()
    
        @items.setter
        def items(self, data):
            if len(data) == len(self.items) and all(
                x == y for x, y in zip(data, self.items)
            ):
                return
            self.model().setStringList(data)
            self.items_changed.emit()
    
    # Set Mapping
    self.mapper = QDataWidgetMapper()
    self.mapper.setModel(StorageModel())
    self.mapper.addMapping(self.listview, 0, b"items")
    self.mapper.toFirst()
    
    self.listview.items_changed.connect(self.mapper.submit)