I use QTableView to display and edit a Pandas DataFrame.
I use this method in the TableModel class to remove rows:
def removeRows(self, position, rows, QModelIndex):
start, end = position, rows
self.beginRemoveRows(QModelIndex, start, end) #
self._data.drop(position,inplace=True)
self._data.reset_index(drop=True,inplace=True)
self.endRemoveRows() #
self.layoutChanged.emit()
return True
It works fine until I add comboBox to some cells on the TableView. I use the following codes to add combobox (in the Main class), but when I delete a row it shows the error message (Python 3.10, Pandas 1.4.1):
IndexError: index 2 is out of bounds for axis 0 with size 2 or (Python 3.9, Pandas 1.3.5) : 'IndexError: single positional indexer is out-of-bounds'
count=len(combo_type)
for type in combo_type:
for row_num in range(self.model._data.shape[0]):
# print(i)
combo = CheckableComboBox(dept_list,self.model._data,row_num,type,count)
self.tableView.setIndexWidget(self.model.index(row_num, self.model._data.shape[1] - 2*count), combo)
count=count-1
But if I comment out the two lines: self.beginRemoveRows(QModelIndex, start, end) and self.endRemoveRows() from removeRows method, it works and there are no more error messages. But according to the Qt documents, these two methods must be called.
A removeRows() implementation must call beginRemoveRows() before the
rows are removed from the data structure, and it must call
endRemoveRows() immediately afterwards.
def removeRows(self, position, rows, QModelIndex):
start, end = position, rows
#self.beginRemoveRows(QModelIndex, start, end) # remove
self._data.drop(position,inplace=True)
self._data.reset_index(drop=True,inplace=True)
#self.endRemoveRows() # remove
self.layoutChanged.emit()
return True
I have tried for hours, but I cannot figure this out. Can anyone help me and explain what is wrong with my code, please?
This is my class for Table Model:
from PyQt5 import QtCore
from PyQt5.QtCore import Qt
from datetime import datetime
import pandas as pd
class TableModel(QtCore.QAbstractTableModel):
def __init__(self, data):
super().__init__()
self._data = data
def data(self, index, role):
if role == Qt.DisplayRole or role == Qt.EditRole:
# See below for the nested-list data structure.
# .row() indexes into the outer list,
# .column() indexes into the sub-list
print(index.row(), index.column())
value = self._data.iloc[index.row(), index.column()]
# Perform per-type checks and render accordingly.
if isinstance(value, datetime):
# Render time to YYY-MM-DD.
if pd.isnull(value):
value=datetime.min
return value.strftime("%Y-%m-%d")
if isinstance(value, float):
# Render float to 2 dp
return "%.2f" % value
if isinstance(value, str):
# Render strings with quotes
# return '"%s"' % value
return value
# Default (anything not captured above: e.g. int)
return value
# implement rowCount
def rowCount(self, index):
# The length of the outer list.
return self._data.shape[0]
# implement columnCount
def columnCount(self, index):
# The following takes the first sub-list, and returns
# the length (only works if all rows are an equal length)
return self._data.shape[1]
# implement flags
def flags(self, index):
return Qt.ItemIsSelectable | Qt.ItemIsEnabled | Qt.ItemIsEditable
# implement setData
def setData(self, index, value, role):
if role == Qt.EditRole:
self._data.iloc[index.row(), index.column()] = value
# self._data.iat[index.row(), self._data.shape[1]-1] = value
self.dataChanged.emit(index, index)
return True
def headerData(self, section, orientation, role):
if role == Qt.DisplayRole:
if orientation == Qt.Horizontal:
return str(self._data.columns[section])
if orientation == Qt.Vertical:
return str(self._data.index[section])
def insertRows(self, position, rows, QModelIndex, parent):
self.beginInsertRows(QModelIndex, position, position+rows-1)
default_row=[[None] for _ in range(self._data.shape[1])]
new_df=pd.DataFrame(dict(zip(list(self._data.columns),default_row)))
self._data=pd.concat([self._data,new_df])
self._data=self._data.reset_index(drop=True)
self.endInsertRows()
self.layoutChanged.emit()
return True
def removeRows(self, position, rows, QModelIndex):
start, end = position, rows
self.beginRemoveRows(QModelIndex, start, end) # if remove these 02 lines, it works
self._data.drop(position,inplace=True)
self._data.reset_index(drop=True,inplace=True)
self.endRemoveRows() # if remove these 02 lines, it works
self.layoutChanged.emit()
return True
Class for checkable combobox:
from PyQt5.QtWidgets import QComboBox
from PyQt5.QtCore import Qt
import CONSTANT
class CheckableComboBox(QComboBox):
def __init__(self,item_list, df,number,type,col_offset_value):
super().__init__()
self._changed = False
self.view().pressed.connect(self.handleItemPressed)
self.view().pressed.connect(self.set_df_value)
# Store checked item
self.checked_item=[]
self.checked_item_index=[]
self.type=type
self.col_offset_value=col_offset_value
# DataFrame to be modified
self.df=df
# Order number of the combobox
self.number=number
for i in range(len(item_list)):
self.addItem(item_list[i])
self.setItemChecked(i, False)
# self.activated.connect(self.set_df_value)
def set_df_value(self):
print(self.number)
self.df.iat[self.number,self.df.shape[1]-self.col_offset_value*2+1]=','.join(self.checked_item)
print(self.df)
def setItemChecked(self, index, checked=False):
item = self.model().item(index, self.modelColumn()) # QStandardItem object
if checked:
item.setCheckState(Qt.Checked)
else:
item.setCheckState(Qt.Unchecked)
def set_item_checked_from_list(self,checked_item_index_list):
for i in range(self.count()):
item = self.model().item(i, 0)
if i in checked_item_index_list:
item.setCheckState(Qt.Checked)
else:
item.setCheckState(Qt.Unchecked)
def get_item_checked_from_list(self,checked_item_index_list):
self.checked_item.clear()
self.checked_item.extend(checked_item_index_list)
def handleItemPressed(self, index):
item = self.model().itemFromIndex(index)
if item.checkState() == Qt.Checked:
item.setCheckState(Qt.Unchecked)
if item.text() in self.checked_item:
self.checked_item.remove(item.text())
self.checked_item_index.remove(index.row())
print(self.checked_item)
print(self.checked_item_index)
else:
if item.text()!=CONSTANT.ALL \
and CONSTANT.ALL not in self.checked_item \
and item.text()!=CONSTANT.GWP \
and CONSTANT.GWP not in self.checked_item \
and item.text()!=CONSTANT.NO_ALLOCATION \
and CONSTANT.NO_ALLOCATION not in self.checked_item :
item.setCheckState(Qt.Checked)
self.checked_item.append(item.text())
self.checked_item_index.append(index.row())
print(self.checked_item)
print(self.checked_item_index)
else:
self.checked_item.clear()
self.checked_item_index.clear()
self.checked_item.append(item.text())
self.checked_item_index.append(index.row())
self.set_item_checked_from_list(self.checked_item_index)
self._changed = True
self.check_items()
def hidePopup(self):
if not self._changed:
super().hidePopup()
self._changed = False
def item_checked(self, index):
# getting item at index
item = self.model().item(index, 0)
# return true if checked else false
return item.checkState() == Qt.Checked
def check_items(self):
# traversing the items
checkedItems=[]
for i in range(self.count()):
# if item is checked add it to the list
if self.item_checked(i):
checkedItems.append(self.model().item(i, 0).text())
Main class:
import sys
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import Qt,QDate,QThread
from net_comm_ui import Ui_MainWindow
from PyQt5.QtWidgets import QApplication, QMainWindow
from pathlib import Path
import multiprocessing
from TableModel import TableModel
from CheckableComboBox import CheckableComboBox
import copy
import datetime
import re
import json
from pathlib import Path
import pandas as pd
import os
from net_comm_worker import Worker
from PyQt5.QtWidgets import QApplication, QWidget, QPushButton
from PyQt5.QtCore import pyqtSlot
dept_list = ['A','B','C','D','E','F','G','H']
combo_type=['METHOD','LOB','DEPT','CHANNEL']
class MainWindow(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.tableView = QtWidgets.QTableView()
import pandas as pd
mydict = [{'a': 1, 'b': 2, 'c': 3, 'd': 4},
{'a': 100, 'b': 200, 'c': 300, 'd': 400},
{'a': 1000, 'b': 2000, 'c': 3000, 'd': 4000 }]
self.data=pd.DataFrame(mydict)
print('initial self.data')
print(self.data)
self.data['Allocation Method'] = ''
self.data['Allocation Method Selected']=''
self.data['Allocation LOB'] = ''
self.data['Allocation LOB Selected']=''
self.data['Allocation DEPT'] = ''
self.data['Allocation DEPT Selected']=''
self.data['Allocation CHANNEL'] = ''
self.data['Allocation CHANNEL Selected']=''
self.model = TableModel(self.data)
self.tableView.setModel(self.model)
self.setCentralWidget(self.tableView)
self.setGeometry(600, 200, 500, 300)
count=len(combo_type)
# Set ComboBox to cells
for type in combo_type:
for row_num in range(self.model._data.shape[0]):
# print(i)
combo = CheckableComboBox(dept_list,self.model._data,row_num,type,count)
self.tableView.setIndexWidget(self.model.index(row_num, self.model._data.shape[1] - 2*count), combo)
count=count-1
button = QPushButton('Delete row', self)
button.move(100,200)
button.clicked.connect(self.delete_row)
def delete_row(self):
index = self.tableView.currentIndex()
if index.row()<self.model._data.shape[0]:
self.model.removeRows(index.row(), 1, index)
print('self.model._data')
print(self.model._data)
print('self.data')
print(self.data)
app = QtWidgets.QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec_()
I add one method to add row. Is the self.layoutChanged.emit() is mandatory to update TableView or there is a more efficient way?:
def insertRows(self, position, rows, QModelIndex, parent):
self.beginInsertRows(QModelIndex, position, position+rows-1)
default_row=[[None] for _ in range(self._data.shape[1])]
new_df=pd.DataFrame(dict(zip(list(self._data.columns),default_row)))
self._data=pd.concat([self._data,new_df])
self._data.reset_index(drop=True, inplace=True)
self.endInsertRows()
self.layoutChanged.emit() # ==> is this mandatory?
return True
Your example passes the wrong index to removeRows, which also does not calculate the start and end values correctly. It can be fixed like this:
class MainWindow(QtWidgets.QMainWindow):
...
def delete_row(self):
index = self.tableView.currentIndex()
self.model.removeRows(index.row(), 1)
def insert_row(self):
self.model.insertRows(self.model.rowCount(), 1)
class TableModel(QtCore.QAbstractTableModel):
...
def rowCount(self, parent=QModelIndex()):
...
def columnCount(self, parent=QModelIndex()):
...
def insertRows(self, position, rows, parent=QModelIndex()):
start, end = position, position + rows - 1
if 0 <= start <= end:
self.beginInsertRows(parent, start, end)
for index in range(start, end + 1):
default_row = [[None] for _ in range(self._data.shape[1])]
new_df = pd.DataFrame(dict(zip(list(self._data.columns), default_row)))
self._data = pd.concat([self._data, new_df])
self._data = self._data.reset_index(drop=True)
self.endInsertRows()
return True
return False
def removeRows(self, position, rows, parent=QModelIndex()):
start, end = position, position + rows - 1
if 0 <= start <= end and end < self.rowCount(parent):
self.beginRemoveRows(parent, start, end)
for index in range(start, end + 1):
self._data.drop(index, inplace=True)
self._data.reset_index(drop=True, inplace=True)
self.endRemoveRows()
return True
return False
Related
I have a created a proxy model that filters a QStringListModel and formats the remaining indexes for a QTableView. When the filter is applied the table view continues to display the rows and columns of the original unfiltered data, leaving a large empty scrollable section. I've updated the rows and columns in the model so, I don't think that's the cause. I would have thought it would update that when the dataChanged signal was emitted.
I'm sure the solution is pretty easy but this is my first time making a proxymodel.
# Standard modules
import os
import re
import sys
# QT modules
from Qt import QtGui
from Qt import QtCore
from Qt import QtWidgets
COL_HEIGHT = 10
class TableModel(QtCore.QAbstractProxyModel):
def __init__(self, parent):
super(TableModel, self).__init__(parent)
self.rows = 0
self.cols = 0
self.count = 0
self.i_map = {}
self.p_map = {}
self.regex = re.compile("")
def sourceDataChanged(self, topLeft, bottomRight):
self.dataChanged.emit(self.mapFromSource(topLeft), self.mapFromSource(bottomRight))
def setFilter(self, exp):
self.regex = re.compile(exp)
rows = self.rows-1
cols = self.cols-1
self.build_map(self.sourceModel())
self.dataChanged.emit(self.index(0,0), self.index(rows, cols))
def build_map(self, model):
model_rows = model.rowCount(QtCore.QModelIndex())
self.p_map = {}
self.i_map = {}
count = 0
for i in range(model_rows):
index = model.index(i, 0, QtCore.QModelIndex())
if(self.regex.search(index.data())):
self.p_map[i] = count
self.i_map[count] = index
count += 1
if count:
self.rows = min(count + 1, COL_HEIGHT)
self.cols = count / COL_HEIGHT + 1
else:
self.rows = 0
self.cols = 0
self.count = count
def setSourceModel(self, model):
super(TableModel, self).setSourceModel(model)
self.build_map(model)
model.dataChanged.connect(self.sourceDataChanged)
def mapFromSource(self, index):
i_row = index.row()
if i_row < 0:
return QtCore.QModelIndex()
row = self.p_map[i_row] % COL_HEIGHT
col = self.p_map[i_row] / COL_HEIGHT
return self.createIndex(row, col)
def mapToSource(self, index):
if index.row() < 0:
return QtCore.QModelIndex()
pos = index.row() + index.column() * COL_HEIGHT
if pos in self.i_map:
return self.i_map[pos]
return QtCore.QModelIndex()
def index(self, row, column, parent = QtCore.QModelIndex()):
return self.createIndex(row, column)
def rowCount(self, parent = QtCore.QModelIndex()):
return self.rows
def columnCount(self,parent = QtCore.QModelIndex()):
return self.cols
def parent(self, index):
return QtCore.QModelIndex()
Change to script to emit layoutAboutToBeChanged before the change and layoutChanged after looks to have solved my issue. Thanks to musicamante
for pointing me at them.
Updated code:
def setFilter(self, exp):
self.regex = re.compile(exp)
rows = self.rows-1
cols = self.cols-1
self.layoutAboutToBeChanged.emit()
self.build_map(self.sourceModel())
self.dataChanged.emit(self.index(0,0), self.index(rows, cols))
self.layoutChanged.emit()
There are different ways to change row order in QTableWidget:
by internal move via drag & drop
by separate buttons which shift a selected row up or down by one position
It turned out that these two approaches are not very practical for longer lists and my special purpose.
So, I tried to implement the following approach by assigning the new position by changing cell values:
the first column holds current position number
by editing these numbers I want to assign the new position to this row
I want to allow editing only on the first column
if an invalid position number is entered (within the range of number of rows) nothing should change
if a valid position number is entered the other position numbers in the first column are modified accordingly.
then I can get the rearranged rows in new order by clicking on the column header for sorting by the first column.
Example: position numbers 1,2,3,4,5.
If I change the value in row3,column1 from 3 to 1, the position numbers in the first column should change as follows:
1 --> 2
2 --> 3
3 --> 1
4 --> 4
5 --> 5
However, it seems I get problems with setEditTriggers(QAbstractItemView.NoEditTriggers) and setEditTriggers(QAbstractItemView.DoubleClicked).
Depending on some different code variations I tried, it looks like I still get an EditTrigger although I think I have disabled EditTriggers via self.setEditTriggers(QAbstractItemView.NoEditTriggers).
Or I get RecursionError: maximum recursion depth exceeded while calling a Python object.
Or TypeError: '>' not supported between instances of 'NoneType' and 'int'.
I hope I could make the problem clear enough. What am I doing wrong here?
Code: (minimized non-working example. Should be copy & paste & run)
import sys
from PyQt5.QtWidgets import QMainWindow, QApplication, QWidget, QAction, QTableWidget, QTableWidgetItem, QVBoxLayout, QPushButton, QAbstractItemView
from PyQt5.QtCore import pyqtSlot, Qt
import random
class MyTableWidget(QTableWidget):
def __init__(self):
super().__init__()
self.setColumnCount(3)
self.setRowCount(7)
self.setSortingEnabled(False)
header = self.horizontalHeader()
header.setSortIndicatorShown(True)
header.sortIndicatorChanged.connect(self.sortItems)
self.setSelectionBehavior(QAbstractItemView.SelectRows)
self.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.col_pos = 0
self.oldPosValue = None
self.manualChange = False
self.cellDoubleClicked.connect(self.cell_doubleClicked)
self.cellChanged.connect(self.cell_changed)
def cell_doubleClicked(self):
self.setEditTriggers(QAbstractItemView.NoEditTriggers)
if self.currentColumn() != self.col_pos: # editing allowed only for this column
return
self.setEditTriggers(QAbstractItemView.DoubleClicked)
try:
self.oldPosValue = int(self.currentItem().text())
except:
pass
self.manualChange = True
def cell_changed(self):
if not self.manualChange:
return
self.setEditTriggers(QAbstractItemView.NoEditTriggers)
try:
newPosValue = int(self.currentItem().text())
except:
newPosValue = None
rowChanged = self.currentRow()
print("Value: {} --> {}".format(self.oldPosValue, newPosValue))
if newPosValue>0 and newPosValue<=self.rowCount():
for row in range(self.rowCount()):
if row != rowChanged:
try:
value = int(self.item(row,self.col_pos).text())
if value<newPosValue:
self.item(row,self.col_pos).setData(Qt.EditRole,value+1)
except:
print("Error")
pass
else:
self.item(rowChanged,self.col_pos).setData(Qt.EditRole,self.oldPosValue)
print("New value outside range")
self.manualChange = True
class App(QWidget):
def __init__(self):
super().__init__()
self.title = 'PyQt5 table'
self.initUI()
def initUI(self):
self.setWindowTitle(self.title)
self.setGeometry(0,0,400,300)
self.layout = QVBoxLayout()
self.tw = MyTableWidget()
self.layout.addWidget(self.tw)
self.pb_refill = QPushButton("Refill")
self.pb_refill.clicked.connect(self.on_click_pb_refill)
self.layout.addWidget(self.pb_refill)
self.setLayout(self.layout)
self.show()
#pyqtSlot()
def on_click_pb_refill(self):
self.tw.setEditTriggers(QAbstractItemView.NoEditTriggers)
for row in range(self.tw.rowCount()):
for col in range(self.tw.columnCount()):
if col==0:
number = row+1
else:
number = random.randint(1000,9999)
twi = QTableWidgetItem()
self.tw.setItem(row, col, twi)
self.tw.item(row, col).setData(Qt.EditRole,number)
if __name__ == '__main__':
app = QApplication(sys.argv)
ex = App()
sys.exit(app.exec_())
Result:
The main problem is that you're trying to disable editing in the wrong way: toggling the edit triggers won't give you a valid result due to the way the view reacts to events.
The recursion error is due to the fact that you are changing data in the signal that reacts to data changes, which clearly is not a good thing to do.
The other problem is related to the current item, which could become None in certain situations.
First of all, the correct way to disable editing of items is by setting the item's flags. This solves another problem you didn't probably found yet: pressing Tab while in editing mode, allows to change data in the other columns.
Then, in order to correctly use the first column to set the order, you should ensure that all other rows get correctly "renumbered". Since doing that also requires setting data in other items, you must temporarily disconnect from the changed signal.
class MyTableWidget(QTableWidget):
def __init__(self):
super().__init__()
self.setColumnCount(3)
self.setRowCount(7)
self.setSortingEnabled(False)
header = self.horizontalHeader()
header.setSortIndicatorShown(True)
header.sortIndicatorChanged.connect(self.sortItems)
self.setSelectionBehavior(QAbstractItemView.SelectRows)
self.setEditTriggers(QAbstractItemView.DoubleClicked)
self.itemChanged.connect(self.cell_changed)
def cell_changed(self, item):
if item.column():
return
newRow = item.data(Qt.DisplayRole)
self.itemChanged.disconnect(self.cell_changed)
if not 1 <= newRow <= self.rowCount():
if newRow < 1:
newRow = 1
item.setData(Qt.DisplayRole, 1)
elif newRow > self.rowCount():
newRow = self.rowCount()
item.setData(Qt.DisplayRole, self.rowCount())
otherItems = []
for row in range(self.rowCount()):
otherItem = self.item(row, 0)
if otherItem == item:
continue
otherItems.append(otherItem)
otherItems.sort(key=lambda i: i.data(Qt.DisplayRole))
for r, item in enumerate(otherItems, 1):
if r >= newRow:
r += 1
item.setData(Qt.DisplayRole, r)
self.itemChanged.connect(self.cell_changed)
def setItem(self, row, column, item):
# override that automatically disables editing if the item is not on the
# first column of the table
self.itemChanged.disconnect(self.cell_changed)
super().setItem(row, column, item)
if column:
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
self.itemChanged.connect(self.cell_changed)
Note that you must also change the function that creates the items and use item.setData before adding the item to the table:
def on_click_pb_refill(self):
for row in range(self.tw.rowCount()):
for col in range(self.tw.columnCount()):
if col==0:
number = row+1
else:
number = random.randint(1000,9999)
twi = QTableWidgetItem()
twi.setData(Qt.EditRole, number)
self.tw.setItem(row, col, twi)
You can use slightly modified QStandardItemModel and QSortFilterProxyModel for that
from PyQt5 import QtWidgets, QtCore, QtGui
from PyQt5.QtCore import Qt, pyqtSignal
import random
from contextlib import suppress
def shiftRows(old, new, count):
items = list(range(1, count + 1))
item = items.pop(old - 1)
items.insert(new - 1, item)
return {item: i + 1 for i, item in enumerate(items)}
class Model(QtGui.QStandardItemModel):
orderChanged = pyqtSignal()
def __init__(self, rows, columns, parent = None):
super().__init__(rows, columns, parent)
self._moving = True
for row in range(self.rowCount()):
self.setData(self.index(row, 0), int(row + 1))
self.setData(self.index(row, 1), random.randint(1000,9999))
self.setData(self.index(row, 2), random.randint(1000,9999))
self._moving = False
def swapRows(self, old, new):
self._moving = True
d = shiftRows(old, new, self.rowCount())
for row in range(self.rowCount()):
index = self.index(row, 0)
v = index.data()
if d[v] != v:
self.setData(index, d[v])
self.orderChanged.emit()
self._moving = False
def flags(self, index):
if index.column() == 0:
return Qt.ItemIsEditable | Qt.ItemIsSelectable | Qt.ItemIsEnabled
return Qt.ItemIsSelectable | Qt.ItemIsEnabled
def headerData(self, section, orientation, role):
if orientation == Qt.Vertical and role == Qt.DisplayRole:
return self.index(section, 0).data()
return super().headerData(section, orientation, role)
def setData(self, index, value, role = Qt.DisplayRole):
if role == Qt.EditRole and index.column() == 0:
if self._moving:
return super().setData(self, index, value, role)
with suppress(ValueError):
value = int(value)
if value < 1 or value > self.rowCount():
return False
prev = index.data()
self.swapRows(prev, value)
return True
return super().setData(index, value, role)
if __name__ == "__main__":
app = QtWidgets.QApplication([])
model = Model(5, 3)
sortModel = QtCore.QSortFilterProxyModel()
sortModel.setSourceModel(model)
model.orderChanged.connect(lambda: sortModel.sort(0))
view = QtWidgets.QTableView()
view.setModel(sortModel)
view.show()
app.exec_()
How do I properly remove items from my custom QAbstractTableModel? Do i need to change this to QStandardItemModel instead?
This is the before:
This is the after...it leaves empty rows and the selection doesn't seem to clear either.
import os
import sys
from PySide import QtCore, QtGui
import random
class CustomJobs(object):
def __init__(self, **kwargs):
super(CustomJobs, self).__init__()
# instance properties
self.name = ''
self.status = ''
# initialize attribute values
for k, v in kwargs.items():
if hasattr(self, k):
setattr(self, k, v)
class PlayblastTableModel(QtCore.QAbstractTableModel):
HEADERS = ['Name', 'Status']
def __init__(self):
super(PlayblastTableModel, self).__init__()
self.items = []
def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole):
if orientation == QtCore.Qt.Horizontal:
if role == QtCore.Qt.DisplayRole:
return self.HEADERS[section]
return None
def columnCount(self, parent=QtCore.QModelIndex()):
return len(self.HEADERS)
def rowCount(self, parent=QtCore.QModelIndex()):
return len(self.items)
def addItem(self, *items):
self.beginInsertRows(QtCore.QModelIndex(), self.rowCount(), self.rowCount() + len(items) - 1)
for item in items:
assert isinstance(item, CustomJobs)
self.items.append(item)
self.endInsertRows()
def removeItems(self, items):
self.beginRemoveRows(QtCore.QModelIndex(), self.rowCount(), self.rowCount())
self.items = [x for x in self.items if x not in items]
self.endRemoveRows()
def clear(self):
self.beginRemoveRows(QtCore.QModelIndex(), 0, self.rowCount())
self.items = []
self.endRemoveRows()
def data(self, index, role=QtCore.Qt.DisplayRole):
if not index.isValid():
return
row = index.row()
col = index.column()
if 0 <= row < self.rowCount():
item = self.items[row]
if role == QtCore.Qt.DisplayRole:
if col == 0:
return item.name
elif col == 1:
return item.status.title()
elif role == QtCore.Qt.UserRole:
return item
return None
class CustomJobsQueue(QtGui.QWidget):
'''
Description:
Widget that manages the Jobs Queue
'''
def __init__(self):
super(CustomJobsQueue, self).__init__()
self.resize(400,600)
# controls
self.uiAddNewJob = QtGui.QPushButton('Add')
self.uiRemoveSelectedJobs = QtGui.QPushButton('Remove')
self.playblastJobModel = PlayblastTableModel()
self.uiJobTableView = QtGui.QTableView()
self.uiJobTableView.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
self.uiJobTableView.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
self.uiJobTableView.setSelectionMode(QtGui.QAbstractItemView.ExtendedSelection)
self.uiJobTableView.setModel(self.playblastJobModel)
self.jobSelection = self.uiJobTableView.selectionModel()
# sub layouts
self.jobQueueToolsLayout = QtGui.QHBoxLayout()
self.jobQueueToolsLayout.addWidget(self.uiAddNewJob)
self.jobQueueToolsLayout.addWidget(self.uiRemoveSelectedJobs)
self.jobQueueToolsLayout.addStretch()
# layout
self.mainLayout = QtGui.QVBoxLayout()
self.mainLayout.addLayout(self.jobQueueToolsLayout)
self.mainLayout.addWidget(self.uiJobTableView)
self.setLayout(self.mainLayout)
# connections
self.uiAddNewJob.clicked.connect(self.addNewJob)
self.uiRemoveSelectedJobs.clicked.connect(self.removeSelectedJobs)
# methods
def addNewJob(self):
name = random.choice(['Kevin','Suzie','Melissa'])
status = random.choice(['error','warning','successa'])
job = CustomJobs(name=name, status=status)
self.playblastJobModel.addItem(job)
def removeSelectedJobs(self):
jobs = self.getSelectedJobs()
self.playblastJobModel.removeItems(jobs)
def getSelectedJobs(self):
jobs = [x.data(QtCore.Qt.UserRole) for x in self.jobSelection.selectedRows()]
return jobs
def main():
app = QtGui.QApplication(sys.argv)
window = CustomJobsQueue()
window.show()
app.exec_()
if __name__ == '__main__':
main()
The reason for this behavior is that you're using the wrong row in beginRemoveRows(): you should use the row number you're removing, and since you're using rowCount() that row index is invalid.
def removeItems(self, items):
self.beginRemoveRows(QtCore.QModelIndex(), self.rowCount() - 1, self.rowCount() - 1)
self.items = [x for x in self.items if x not in items]
self.endRemoveRows()
To be more correct, you should remove the actual rows in the model. In your simple case it won't matter that much, but in case your model becomes more complex, keep in mind this.
def removeItems(self, items):
removeRows = []
for row, item in enumerate(self.items):
if item in items:
removeRows.append(row)
for row in sorted(removeRows, reverse=True):
self.beginRemoveRows(QtCore.QModelIndex(), row, row)
self.items.pop(row)
self.endRemoveRows()
The reason for the reversed row order in the for cycle is that for list consistency reasons the row removal should always begin from the bottom. This can be important if you want to remove rows arbitrarily while keeping the current selection in case the removed items are not selected.
That said, as already suggested in the comments, if you don't need specific behavior and implementation, creating a QAbstractItemModel (or any abstract model) subclass is unnecessary, as QStandardItemModel will usually be enough, as it already provides all required features (including drag and drop support, which can be rather complex if you don't know how the Qt data model works).
Well, unless it's for learning purposes, obviously.
I've seen questions similar to this one but they are aimed at QTableView. This is not using that,, this is just for a dropdown (QComboBox) with a custom QAbstractTableModel, which needs to have 2 columns.
BIG UPDATE
(Note: Legacy code has been deleted as this is a better approach on the same question, and legacy code was confusing as hell).
Okay, so trying to catch up with what #eyllanesc explained, I changed this from a QAbstractListModel to a QAbstractTableModel. The result is:
class ModelForComboboxesWithID(QAbstractTableModel):
"""Create our basic model"""
def __init__(self, program, records):
super(ModelForComboboxesWithID, self).__init__()
self._data = records
self.program = program
self.path_images = program.PATH_IMAGES
def rowCount(self, index: int = 0) -> int:
"""The length of the outer list. Structure: [row, row, row]"""
if not self._data:
return 0 # Doubt: Do we need to return this if self._data is empty?
return len(self._data)
def columnCount(self, index: int = 0) -> int:
"""The length of the sub-list inside the outer list. Meaning that Columns are inside rows
Structure: [row [column], row [column], row [column]]"""
if not self._data:
return 0 # Doubt: Do we need to return this if self._data is empty?
return len(self._data[0])
def data(self, index, role=None):
"""return the data on this index as data[row][column]"""
# 1 - Display data based on its content (this edits the text that you visually see)
if role == Qt.DisplayRole:
value = self._data[index.row()][index.column()]
return value
# 2 - Tooltip displayed when hovering on it
elif role == Qt.ToolTipRole:
return f"ID: {self._data[index.row()][1]}"
Which I set this way:
def eventFilter(self, target, event: QEvent):
if event.type() == QEvent.MouseButtonPress:
if target == self.Buscadorcombo_cliente:
records = ... # my query to the database
set_combo_records_with_ids(self.program, target, records)
target.currentIndexChanged.connect(self.test)
def set_combo_records_with_ids(program, combobox: QComboBox, records):
"""Clear combobox, set model/data and sort it"""
combobox.clear()
model = ModelForComboboxesWithID(program, records)
combobox.setModel(model)
combobox.model().sort(0, Qt.AscendingOrder)
combobox.setModelColumn(0)
The result of this works almost perfect:
On the dropdown(Combobox) it displays the name.
If you hover on an item, it displays the ID.
Now I am able to get any data of it this way.
def test(self, index):
data_id = self.Buscadorcombo_cliente.model().index(index, 1).data()
data_name = self.Buscadorcombo_cliente.model().index(index, 0).data()
print(data_id)
print(data_name)
You have to set a QTableView as a view:
from PySide2 import QtGui, QtWidgets
def main():
import sys
app = QtWidgets.QApplication(sys.argv)
w = QtWidgets.QWidget()
combo = QtWidgets.QComboBox()
model = QtGui.QStandardItemModel(0, 2)
for i in range(10):
items = []
for j in range(model.columnCount()):
it = QtGui.QStandardItem(f"it-{i}{j}")
items.append(it)
model.appendRow(items)
combo.setModel(model)
view = QtWidgets.QTableView(
combo, selectionBehavior=QtWidgets.QAbstractItemView.SelectRows
)
combo.setView(view)
view.verticalHeader().hide()
view.horizontalHeader().hide()
header = view.horizontalHeader()
for i in range(header.count()):
header.setSectionResizeMode(i, QtWidgets.QHeaderView.Stretch)
lay = QtWidgets.QVBoxLayout(w)
lay.addWidget(combo)
lay.addStretch()
w.resize(640, 480)
w.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()
I'm using my custom item model (subclassed from QAbstractItemModel) with custom QTreeView. I want to allow internal drag-n-drop movement (MoveAction) and, when modifier key or right mouse button is pressed, pass CopyAction to my model (to dropMimeData) to copy items. However, default implementation of dropEvent() in QTreeView seems (from C code) only capable of passing MoveAction but when I try to reimplement dropEvent() in my QTreeView subclass like this:
def dropEvent(self, e):
index = self.indexAt(e.pos())
parent = index.parent()
self.model().dropMimeData(e.mimeData(), e.dropAction(), index.row(), index.column(), parent)
e.accept()
... it works, but works horribly in terms of user interaction because there are tons of comlex code determining right index to drop item on in default implementation.
When i'm trying to modify action and call to superclass: super(Tree, self).dropEvent(e) dropAction() data is also lost.
What can I do in order to modify dropAction without loosing all fancy things that default dropEvent is doing for me?
Horrible mess of my current WIP code (i hope it's somewhere near minimal example)
from copy import deepcopy
import pickle
import config_editor
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import Qt as Qt
from PyQt5.QtGui import QCursor, QStandardItemModel
from PyQt5.QtWidgets import QAbstractItemView, QTreeView, QMenu
class ConfigModelItem:
def __init__(self, label, value="", is_section=False, state='default', parent=None):
self.itemData = [label, value]
self.is_section = is_section
self.state = state
self.childItems = []
self.parentItem = parent
if self.parentItem is not None:
self.parentItem.appendChild(self)
def appendChild(self, item):
self.childItems.append(item)
item.parentItem = self
def addChildren(self, items, row):
if row == -1:
row = 0
self.childItems[row:row] = items
for item in items:
item.parentItem = self
def child(self, row):
return self.childItems[row]
def childCount(self):
return len(self.childItems)
def columnCount(self):
return 2
def data(self, column):
try:
return self.itemData[column]
except IndexError:
return None
def set_data(self, data, column):
try:
self.itemData[column] = data
except IndexError:
return False
return True
def parent(self):
return self.parentItem
def row(self):
if self.parentItem is not None:
return self.parentItem.childItems.index(self)
return 0
def removeChild(self, position):
if position < 0 or position > len(self.childItems):
return False
child = self.childItems.pop(position)
child.parentItem = None
return True
def __repr__(self):
return str(self.itemData)
class ConfigModel(QtCore.QAbstractItemModel):
def __init__(self, data, parent=None):
super(ConfigModel, self).__init__(parent)
self.rootItem = ConfigModelItem("Option", "Value")
self.setup(data)
def headerData(self, section, orientation, role):
if role == Qt.DisplayRole and orientation == Qt.Horizontal:
return self.rootItem.data(section)
def columnCount(self, parent):
return 2
def rowCount(self, parent):
if parent.column() > 0:
return 0
if not parent.isValid():
parentItem = self.rootItem
else:
parentItem = parent.internalPointer()
return parentItem.childCount()
def index(self, row, column, parent):
if not self.hasIndex(row, column, parent):
return QtCore.QModelIndex()
parentItem = self.nodeFromIndex(parent)
childItem = parentItem.child(row)
if childItem:
return self.createIndex(row, column, childItem)
else:
return QtCore.QModelIndex()
def parent(self, index):
if not index.isValid():
return QtCore.QModelIndex()
childItem = index.internalPointer()
parentItem = childItem.parent()
if parentItem == self.rootItem or parentItem is None:
return QtCore.QModelIndex()
return self.createIndex(parentItem.row(), 0, parentItem)
def nodeFromIndex(self, index):
if index.isValid():
return index.internalPointer()
return self.rootItem
def data(self, index, role):
if not index.isValid():
return None
item = index.internalPointer()
if role == Qt.DisplayRole or role == Qt.EditRole:
return item.data(index.column())
return None
def setData(self, index, value, role=Qt.EditRole):
if not index.isValid():
return False
item = index.internalPointer()
if role == Qt.EditRole:
item.set_data(value, index.column())
self.dataChanged.emit(index, index, (role,))
return True
def flags(self, index):
if not index.isValid():
return QtCore.Qt.ItemIsDragEnabled | QtCore.Qt.ItemIsDropEnabled # Qt.NoItemFlags
item = index.internalPointer()
flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable
if index.column() == 0:
flags |= int(QtCore.Qt.ItemIsDragEnabled)
if item.is_section:
flags |= int(QtCore.Qt.ItemIsDropEnabled)
if index.column() == 1 and not item.is_section:
flags |= Qt.ItemIsEditable
return flags
def supportedDropActions(self):
return QtCore.Qt.CopyAction | QtCore.Qt.MoveAction
def mimeTypes(self):
return ['app/configitem', 'text/xml']
def mimeData(self, indexes):
mimedata = QtCore.QMimeData()
index = indexes[0]
mimedata.setData('app/configitem', pickle.dumps(self.nodeFromIndex(index)))
return mimedata
def dropMimeData(self, mimedata, action, row, column, parentIndex):
print('action', action)
if action == Qt.IgnoreAction:
return True
droppedNode = deepcopy(pickle.loads(mimedata.data('app/configitem')))
print('copy', action & Qt.CopyAction)
print(droppedNode.itemData, 'node')
self.insertItems(row, [droppedNode], parentIndex)
self.dataChanged.emit(parentIndex, parentIndex)
if action & Qt.CopyAction:
return False # to not delete original item
return True
def removeRows(self, row, count, parent):
print('rem', row, count)
self.beginRemoveRows(parent, row, row+count-1)
parentItem = self.nodeFromIndex(parent)
for x in range(count):
parentItem.removeChild(row)
self.endRemoveRows()
print('removed')
return True
#QtCore.pyqtSlot()
def removeRow(self, index):
parent = index.parent()
self.beginRemoveRows(parent, index.row(), index.row())
parentItem = self.nodeFromIndex(parent)
parentItem.removeChild(index.row())
self.endRemoveRows()
return True
def insertItems(self, row, items, parentIndex):
print('ins', row)
parent = self.nodeFromIndex(parentIndex)
self.beginInsertRows(parentIndex, row, row+len(items)-1)
parent.addChildren(items, row)
print(parent.childItems)
self.endInsertRows()
self.dataChanged.emit(parentIndex, parentIndex)
return True
def setup(self, data: dict, parent=None):
if parent is None:
parent = self.rootItem
for key, value in data.items():
if isinstance(value, dict):
item = ConfigModelItem(key, parent=parent, is_section=True)
self.setup(value, parent=item)
else:
parent.appendChild(ConfigModelItem(key, value))
def to_dict(self, parent=None) -> dict:
if parent is None:
parent = self.rootItem
data = {}
for item in parent.childItems:
item_name, item_data = item.itemData
if item.childItems:
data[item_name] = self.to_dict(item)
else:
data[item_name] = item_data
return data
#property
def dict(self):
return self.to_dict()
class ConfigDialog(config_editor.Ui_config_dialog):
def __init__(self, data):
super(ConfigDialog, self).__init__()
self.model = ConfigModel(data)
def setupUi(self, config_dialog):
super(ConfigDialog, self).setupUi(config_dialog)
self.config_view = Tree()
self.config_view.setObjectName("config_view")
self.config_view.setModel(self.model)
self.gridLayout.addWidget(self.config_view, 0, 0, 1, 1)
self.config_view.expandAll()
#self.config_view.setDragDropMode(True)
#self.setDragDropMode(QAbstractItemView.InternalMove)
#self.setDragEnabled(True)
#self.setAcceptDrops(True)
#self.setDropIndicatorShown(True)
self.delete_button.pressed.connect(self.remove_selected)
def remove_selected(self):
index = self.config_view.selectedIndexes()[0]
self.model.removeRow(index)\
class Tree(QTreeView):
def __init__(self):
QTreeView.__init__(self)
self.setContextMenuPolicy(Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self.open_menu)
self.setSelectionMode(self.SingleSelection)
self.setDragDropMode(QAbstractItemView.InternalMove)
self.setDragEnabled(True)
self.setAcceptDrops(True)
self.setDropIndicatorShown(True)
self.setAnimated(True)
def dropEvent(self, e):
print(e.dropAction(), 'baseact', QtCore.Qt.CopyAction)
# if e.keyboardModifiers() & QtCore.Qt.AltModifier:
# #e.setDropAction(QtCore.Qt.CopyAction)
# print('copy')
# else:
# #e.setDropAction(QtCore.Qt.MoveAction)
# print("drop")
print(e.dropAction())
#super(Tree, self).dropEvent(e)
index = self.indexAt(e.pos())
parent = index.parent()
print('in', index.row())
self.model().dropMimeData(e.mimeData(), e.dropAction(), index.row(), index.column(), parent)
e.accept()
def open_menu(self):
menu = QMenu()
menu.addAction("Create new item")
menu.exec_(QCursor.pos())
if __name__ == '__main__':
import sys
def except_hook(cls, exception, traceback):
sys.__excepthook__(cls, exception, traceback)
sys.excepthook = except_hook
app = QtWidgets.QApplication(sys.argv)
Dialog = QtWidgets.QDialog()
data = {"section 1": {"opt1": "str", "opt2": 123, "opt3": 1.23, "opt4": False, "...": {'subopt': 'bal'}},
"section 2": {"opt1": "str", "opt2": [1.1, 2.3, 34], "opt3": 1.23, "opt4": False, "...": ""}}
ui = ConfigDialog(data)
ui.setupUi(Dialog)
print(Qt.DisplayRole)
Dialog.show()
print(app.exec_())
print(Dialog.result())
print(ui.model.to_dict())
sys.exit()
setDragDropMode(QAbstractItemView.InternalMove) only allows move operations (as the name would suggest, although the docs do leave some uncertainty in the way this is stated). You probably want to set it to QAbstractItemView.DragDrop mode. You can set the default action with setDefaultDropAction(). Other than that, it's up to the model to return the right item flags and supportedDropActions()/canDropMimeData(), which it looks like yours does. There's also a dragDropOverwriteMode property which may be interesting.
One thing that has surprised me before is that in the model's dropMimeData() method if you return True from a Qt.MoveAction, the QAbstractItemView will remove the dragged item from the model automatically (with a removeRows()/removeColumns() call to your model). This can cause some puzzling results if your model has already actually moved that row (and deleted the old one). I never quite understood that behavior. OTOH if you return False it doesn't matter to the item view, as long as the data is actually moved/updated properly.