Laure Ravier
Laure Ravier

Reputation: 55

QStyledItemDelegate: how do I make it replace the current data and not just be on top of it?

I'm pretty new to Python and PyQT5, so forgive me if there is a very simple answer to this question! I have tried to find an answer in the documentation/on here but no luck so far.

I've been trying to display a pandas dataframe in a QTableView, which is working fine now. I want to add a column containing checkboxes which will ultimately determine what happens with an entire row. I have subclassed QStyledItemDelegate like this:

class CheckBoxDelegate(QStyledItemDelegate):
    def __init__(self, parent):
        super(CheckBoxDelegate, self).__init__(parent)

    def createEditor(self, parent, option, index):
        editor = QCheckBox(parent)
        editor.stateChanged.connect(self.commit_editor)
        return editor

    def commit_editor(self):
        editor = self.sender()
        self.commitData.emit(editor)

Which gives me the behavior I want, but I don't want the delegate to be on top of the original data, which is happening now:

enter image description here

I'm setting the delegate here:

self.table.setItemDelegateForColumn(0, checkbox_delegate)

The model is going to be used to propose changes to a database to a user. I want to add a column with just checkboxes so a user can exclude certain rows from being changed. I know I could use Qt.ItemIsUserCheckable but as far as I could find, I can't use that if I want to show just the checkbox (and not the field values). I am aware that it's possible to select rows in other ways, but I don't feel like those are appropriate for my purposes here.

Full code: (this is just a quick experiment, not meant for production as is)

import sys
import pandas as pd
from PyQt5.QtWidgets import QApplication, QTableView, QVBoxLayout, QDialog, QAbstractItemView, QCheckBox, QStyledItemDelegate
from PyQt5.QtCore import Qt, QAbstractTableModel, QVariant, QModelIndex, QSortFilterProxyModel

class PandasModel(QAbstractTableModel):
    def __init__(self, df):
        super(PandasModel, self).__init__()
        self._data = df
        self.num_rows, self.num_columns = self._data.shape

    def columnCount(self, parent=None):
        return self.num_columns

    def rowCount(self, parent=None):
        return self.num_rows

    def data(self, index, role):
        if role == Qt.DisplayRole or role == Qt.EditRole:
            # print('setting')
            return QVariant('{}'.format(self._data.iloc[index.row(), index.column()]))
        else:
            return QVariant()

    def setData(self, index, value, role):
        if not index.isValid():
            return False
        if role == Qt.EditRole:
            self._data.iloc[index.row(), index.column()] = value
            self.dataChanged.emit(index, index)
            return True
        return QVariant()

    def flags(self, index):
        flags = Qt.ItemIsEditable | Qt.ItemIsEnabled
        return flags

    def sort(self, Ncol, order):
        """Sort table by given column number.
        """
        self.layoutAboutToBeChanged.emit()
        self._data = self._data.sort_values(self._data.columns[Ncol], ascending=not order)
        self.layoutChanged.emit()
        self.dataChanged.emit(QModelIndex(), QModelIndex())

class CheckBoxDelegate(QStyledItemDelegate):
    def __init__(self, parent):
        super(CheckBoxDelegate, self).__init__(parent)

    def createEditor(self, parent, option, index):
        editor = QCheckBox(parent)
        editor.stateChanged.connect(self.commit_editor)
        return editor

    def commit_editor(self):
        editor = self.sender()
        self.commitData.emit(editor)

class PandasWidget(QDialog):
    def __init__(self, df, selectable_rows=True):
        super(PandasWidget, self).__init__()
        self._df = df
        if selectable_rows:
            self._df.insert(0, 'Include row', True)
        self.table = QTableView()
        layout = QVBoxLayout()
        model = PandasModel(self._df)
        proxy_model = QSortFilterProxyModel()
        proxy_model.setSourceModel(model)
        self.table.setModel(proxy_model)
        self.table.setSelectionBehavior(QTableView.SelectRows)
        self.table.setSelectionMode(QAbstractItemView.MultiSelection)
        self.table.setSortingEnabled(True)
        checkbox_delegate = CheckBoxDelegate(self.table)
        self.table.setItemDelegateForColumn(0, checkbox_delegate)
        for i in range(self.table.model().rowCount(self.table.rootIndex())):
            self.table.openPersistentEditor(self.table.model().index(i, 0, self.table.rootIndex()))
        layout.addWidget(self.table)
        self.setLayout(layout)

if __name__ == '__main__':
    app = QApplication(sys.argv)
    data = {
        'Col X': list('ABCD'),
        'col Y': [10, 20, 30, 40]
    }
    df = pd.DataFrame(data)
    demo = PandasWidget(df)
    demo.exec_()

Upvotes: 1

Views: 891

Answers (2)

musicamante
musicamante

Reputation: 48231

In order to display a checkbox in an item view it's not necessary to use a delegate, but to correctly return a CheckState value when the CheckStateRole is used for data():

    def data(self, index, role):
        if index.column() == 0:
            # this if block automatically avoids returning values for the display
            # role on the first column
            if role == Qt.CheckStateRole:
                value = self._data.iloc[index.row(), index.column()]
                return Qt.Checked if value else Qt.Unchecked
        elif role in (Qt.DisplayRole, Qt.EditRole):
            # print('setting')
            return self._data.iloc[index.row(), index.column()]

Note: it's not required to return QVariant(), as PyQt automatically converts python objects to values suitable for Qt.

If interaction is required for the checkbox, then both flags() and setData() must be properly implemented:

    def setData(self, index, value, role):
        if not index.isValid():
            return False
        if role == Qt.CheckStateRole:
            self._data.iloc[index.row(), index.column()] = bool(value)
            self.dataChanged.emit(index, index)
            return True
        elif role == Qt.EditRole:
            self._data.iloc[index.row(), index.column()] = value
            self.dataChanged.emit(index, index)
            return True
        return False

    def flags(self, index):
        flags = super().flags(index)
        if index.column() == 0:
            flags |= Qt.ItemIsUserCheckable
        else:
            flags |= Qt.ItemIsEditable
        return flags

Some slightly unrelated considerations about your usage of the delegate:

  • as already reported in the comments, painting functions should only do painting operations; luckily, openPersistentEditor() is smart enough to do nothing if an editor already exists, but nonetheless it's good practice to avoid any operation that would involve changes in the "layout" of a widget, as it can potentially lead to infinite recursion;
  • self.sender() should be avoid when possible; in your case you could have just used a lambda: editor.stateChanged.connect(lambda: self.commitData.emit(editor));
  • you didn't use the signal argument, so it's not an issue, but be aware that stateChanged emits a CheckState, not a boolean (so Checked is actually 2, and that's why I used bool(value) in setData()); it's usually better to use toggle instead;

Upvotes: 1

eyllanesc
eyllanesc

Reputation: 243887

A possible solution is to create a different model that stores the states of the checkBox and concatenate it to the initial model:

import sys
import pandas as pd

from PyQt5.QtCore import (
    Qt,
    QAbstractTableModel,
    QVariant,
    QModelIndex,
    QSortFilterProxyModel,
    QTransposeProxyModel,
    QConcatenateTablesProxyModel,
)
from PyQt5.QtGui import QStandardItem, QStandardItemModel
from PyQt5.QtWidgets import (
    QApplication,
    QTableView,
    QVBoxLayout,
    QDialog,
    QAbstractItemView,
    QHeaderView,
)


class PandasModel(QAbstractTableModel):
    def __init__(self, df):
        super(PandasModel, self).__init__()
        self._data = df
        self.num_rows, self.num_columns = self._data.shape

    def columnCount(self, parent=None):
        return self.num_columns

    def rowCount(self, parent=None):
        return self.num_rows

    def data(self, index, role):
        if role == Qt.DisplayRole or role == Qt.EditRole:
            # print('setting')
            return QVariant("{}".format(self._data.iloc[index.row(), index.column()]))
        else:
            return QVariant()

    def setData(self, index, value, role):
        if not index.isValid():
            return False
        if role == Qt.EditRole:
            self._data.iloc[index.row(), index.column()] = value
            self.dataChanged.emit(index, index)
            return True
        return QVariant()

    def flags(self, index):
        flags = Qt.ItemIsEditable | Qt.ItemIsEnabled
        return flags

    def sort(self, Ncol, order):
        """Sort table by given column number."""
        self.layoutAboutToBeChanged.emit()
        self._data = self._data.sort_values(
            self._data.columns[Ncol], ascending=not order
        )
        self.layoutChanged.emit()
        self.dataChanged.emit(QModelIndex(), QModelIndex())


class PandasWidget(QDialog):
    def __init__(self, df, selectable_rows=True):
        super(PandasWidget, self).__init__()
        self._df = df
        if selectable_rows:
            self._df.insert(0, "Include row", True)
        self.table = QTableView()

        model = PandasModel(self._df)

        checkmodel = QStandardItemModel(0, model.columnCount())
        items = []
        for i in range(model.columnCount()):
            it = QStandardItem()
            it.setCheckable(True)
            items.append(it)
        checkmodel.appendRow(items)

        transpose_model = QTransposeProxyModel()
        transpose_model.setSourceModel(model)

        concatenate_model = QConcatenateTablesProxyModel()
        concatenate_model.addSourceModel(checkmodel)
        concatenate_model.addSourceModel(transpose_model)

        transpose_model_2 = QTransposeProxyModel()
        transpose_model_2.setSourceModel(concatenate_model)

        proxy_model = QSortFilterProxyModel()
        proxy_model.setSourceModel(transpose_model_2)
        self.table.setModel(proxy_model)

        self.table.setSelectionBehavior(QTableView.SelectRows)
        self.table.setSelectionMode(QAbstractItemView.MultiSelection)
        self.table.setSortingEnabled(True)

        self.table.horizontalHeader().setSectionResizeMode(
            0, QHeaderView.ResizeToContents
        )

        layout = QVBoxLayout(self)
        layout.addWidget(self.table)


if __name__ == "__main__":
    app = QApplication(sys.argv)
    data = {"Col X": list("ABCD"), "col Y": [10, 20, 30, 40]}
    df = pd.DataFrame(data)
    demo = PandasWidget(df)
    demo.exec_()

enter image description here

Upvotes: 2

Related Questions