简体   繁体   中英

How to get PyQt5 QListView indices to match model's list indices after drag and drop?

I'm trying to set up internal drag and drop in a QListView backed by a subclassed QAbstractListModel, and it's not completely working due to some strange behaviour that I don't know how to deal with.

The drag and drop seems to go well, with the underlying Model's list being reordered, but now the ListView's indices are shifted and don't match the underlying model. For example, say the list starts as [A, B, C, D, E]. I move A to the spot between D and E. Now, in the model, the list is [B, C, D, A, E] and A is index 3. In the list, I see the same thing, but debugging shows that the ModelIndex reported by the ListView for A has row 0. So it's like the View just switched around the ModelIndices while the Model actually reordered the data. Now nothing matches up at all.

(EDIT: on making my minimal example, I discovered that the indexes here don't match up even before moving items. Instead of populating the edit form with the elements of the newly selected item, it just shows the last selected item.)

I haven't seen anything in the examples I'm using to implement this to indicate why this might be happening. Does anyone have any insight into what I might be doing wrong?

Here's an example of my code (edited to be minimal but reproduceable):

[ROOT]/ui/example.py

import os
import pickle
from typing import List, Any, Iterable

from PyQt5 import uic
from PyQt5 import QtWidgets
from PyQt5.QtCore import QAbstractListModel, Qt, QModelIndex, QVariant, \
    QMimeData

from definitions import ROOT_DIR

class Thing:
    def __init__(self,
                 name: str,
                 number: int):
        self._name = name
        self._number = number

    @property
    def name(self):
        return self._name

    @property
    def number(self):
        return self._number

    def __str__(self):
        return f"{self.name} - {self.number}"


class ThingListModel(QAbstractListModel):
    ThingRole = Qt.UserRole
    Mimetype = "application/vnd.something.thing"

    def __init__(self,
                 *args,
                 things: List[Thing] = None,
                 **kwargs):
        super().__init__(*args, **kwargs)
        self.things = things or []

    def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
        return len(self.things)

    def data(self, index: QModelIndex, role=Qt.DisplayRole):
        if not index.isValid() or index.parent().isValid():
            return QVariant()
        if index.row() > len(self.things):
            return None
        if role == Qt.DisplayRole:
            thing = self.things[index.row()]
            return str(thing)
        if role == Qt.EditRole:
            thing = self.things[index.row()]
            return thing.name, thing.number
        if role == ThingListModel.ThingRole:
            return self.things[index.row()]
        return QVariant()

    def setData(self,
                index: QModelIndex,
                value: Any,
                role: int = Qt.EditRole) -> bool:
        fail_message = "Thing model edit failed"
        if not index.isValid():
            return False
        if role == Qt.EditRole:
            if not isinstance(value, Thing):
                return False
            list_index = index.row()
            if not 0 <= list_index < self.rowCount():
                return False
            self.things[list_index] = value
            self.dataChanged.emit(index, index, self.roleNames())
            return True
        return False

    def insertRows(
            self, row: int, count: int,
            parent: QModelIndex = None) -> bool:
        if parent is None:
            parent = QModelIndex()
        self.beginInsertRows(QModelIndex(), row, row + count - 1)
        self.things[row:row] = [Thing("", 1)] * count
        self.endInsertRows()
        return True

    def removeRows(
            self, row: int, count: int,
            parent: QModelIndex = None) -> bool:
        if parent is None:
            parent = QModelIndex()
        self.beginRemoveRows(QModelIndex(), row, row + count - 1)
        del self.things[row:row + count]
        self.endRemoveRows()
        return True

    def flags(self, index: QModelIndex) -> Qt.ItemFlags:
        flags = super().flags(index)
        if index.isValid():
            flags |= Qt.ItemIsEditable
            flags |= Qt.ItemIsDragEnabled
        else:
            flags |= Qt.ItemIsDropEnabled
        return flags

    def supportedDropActions(self) -> Qt.DropActions:
        return Qt.MoveAction

    def mimeTypes(self) -> List[str]:
        return [self.Mimetype]

    def mimeData(self, indexes: Iterable[QModelIndex]) -> 'QMimeData':
        sorted_indices = sorted(
            [index for index in indexes if index.isValid()],
            key=lambda index: index.row())
        encoded_data = pickle.dumps(
            [self.data(
                index, ThingListModel.ThingRole
            ) for index in sorted_indices])
        mime_data = QMimeData()
        mime_data.setData(self.Mimetype, encoded_data)
        return mime_data

    def dropMimeData(self, data: 'QMimeData', action: Qt.DropAction, row: int,
                     column: int, parent: QModelIndex) -> bool:
        if action == Qt.IgnoreAction:
            return True
        if not data.hasFormat(self.Mimetype):
            return False
        if column > 0:
            return False

        encoded_data = data.data(self.Mimetype)
        decoded_data = pickle.loads(encoded_data)
        self.insertRows(row, len(decoded_data))
        for i, thing in enumerate(decoded_data):
            self.setData(self.index(row + i, 0), thing)
        return True


class CustomWindow(QtWidgets.QMainWindow):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        uic.loadUi(os.path.join(ROOT_DIR, "ui", "custom_window.ui"),
                   self, 'ui')

        self.setWindowTitle("Window Title")

        # Models
        self._pastThings = ThingListModel(things=[
            Thing("A", 1),
            Thing("B", 2),
            Thing("C", 3),
            Thing("D", 4),
            Thing("E", 5)
        ])

        # UI
        self.setup_ui()
        self.setup_signals()

    # UI
    def setup_ui(self):
        self.listView_things.setSelectionMode(
            QtWidgets.QAbstractItemView.SingleSelection)
        self.listView_things.setDragDropMode(
            QtWidgets.QAbstractItemView.InternalMove)
        self.listView_things.setDragEnabled(True)
        self.listView_things.setAcceptDrops(True)
        self.listView_things.setDropIndicatorShown(True)
        self.listView_things.setModel(self._pastThings)
        self.listView_things.setCurrentIndex(
            self._pastThings.index(0))

    def setup_signals(self):
        self.listView_things.selectionModel().currentChanged.connect(
            self.on_past_thing_list_selection_changed)
        self.lineEdit_name.textEdited.connect(
            self.on_past_thing_edited)
        self.spinBox_number.valueChanged.connect(
            self.on_past_thing_edited)

    # Signal Handlers
    def on_past_thing_list_selection_changed(self):
        indices = self.listView_things.selectedIndexes()
        name, number = self._pastThings.data(indices[0], Qt.EditRole)
        self.lineEdit_name.setText(name)
        self.spinBox_number.blockSignals(True)
        self.spinBox_number.setValue(number)
        self.spinBox_number.blockSignals(False)

    def on_past_thing_edited(self):
        indices: List[QModelIndex] = self.listView_things.selectedIndexes()
        name = self.lineEdit_name.text()
        number = self.spinBox_number.value()
        self._pastGlues.edit_things(row=indices[0].row(), name=name,
                                    number=number)


if __name__ == '__main__':
    app = QtWidgets.QApplication([])
    window = CustomWindow()
    window.show()
    app.exec_()

[ROOT]/ui/custom_window.ui

<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
 <class>MainWindow</class>
 <widget class="QMainWindow" name="MainWindow">
  <property name="geometry">
   <rect>
    <x>0</x>
    <y>0</y>
    <width>800</width>
    <height>600</height>
   </rect>
  </property>
  <property name="windowTitle">
   <string>MainWindow</string>
  </property>
  <widget class="QWidget" name="centralwidget">
   <layout class="QGridLayout" name="gridLayout">
    <item row="0" column="0">
     <layout class="QHBoxLayout" name="horizontalLayout">
      <item>
       <widget class="QListView" name="listView_things"/>
      </item>
      <item>
       <layout class="QVBoxLayout" name="verticalLayout">
        <item>
         <widget class="QLineEdit" name="lineEdit_name"/>
        </item>
        <item>
         <widget class="QSpinBox" name="spinBox_number"/>
        </item>
        <item>
         <spacer name="verticalSpacer">
          <property name="orientation">
           <enum>Qt::Vertical</enum>
          </property>
          <property name="sizeHint" stdset="0">
           <size>
            <width>20</width>
            <height>40</height>
           </size>
          </property>
         </spacer>
        </item>
       </layout>
      </item>
     </layout>
    </item>
   </layout>
  </widget>
  <widget class="QMenuBar" name="menubar">
   <property name="geometry">
    <rect>
     <x>0</x>
     <y>0</y>
     <width>800</width>
     <height>22</height>
    </rect>
   </property>
  </widget>
  <widget class="QStatusBar" name="statusbar"/>
 </widget>
 <resources/>
 <connections/>
</ui>

[ROOT]/definitions.py (place in project root)

import os

ROOT_DIR = os.path.dirname(os.path.abspath(__file__))

While rewriting my code to actually be minimal and reproducible, I ended up solving my own problem.

My old code connected the currentChanged signal from the selectionModel to a handler that checked what had been selected in the view. It seems that the selectionModel updates before the view does, so I was getting old information. Fortunately, a closer read of the signal showed that it hands out the new current index, so I can just use that.

Here's the fixed code.

def setup_signals(self):
        self.listView_things.selectionModel().currentChanged.connect(
            lambda current, previous: 
            self.on_past_thing_list_selection_changed(current))
        # rest of method unchanged

    # Signal Handlers
    def on_past_thing_list_selection_changed(self, current: QModelIndex):
        name, number = self._pastThings.data(current, Qt.EditRole)
        # rest of method unchanged

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