How to change row order in a QTableWidget by editing cells? - python

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_()

Related

PyQt5: Implement removeRows for pandas table model

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

How to have multiple columns in a QComboBox with a QAbstractTableModel

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()

PyQt: Storing complex metadata in QModelIndex's internalPointer

This might be more appropriate as a bug report, but maybe I'm just misunderstanding something.
I want to implement my own hierarchical QAbstractItemModel to show a tree of objects in a QTreeView. I've started with the editable tree model example [1] which works fine.
However, I need more metadata in my indexes as I have differend types of nodes (or rather nodes have properties) and a simple (row, column, object-pointer)-tuple is not enough. See also Storing two different types in QModelIndex for another example.
So I've created another datastructure which holds this data:
# this is just an example
class TempContainer:
def __init__(self, name, obj):
self.name = name
self.obj = obj
And I create an index like this:
# in TreeModel.index():
o = TempContainer("child", childItem)
self.tmpcache.append(o) # just to keep a reference alive
return self.createIndex(row, column, o)
Accessing objects can now be implemented as follows:
def getItem(self, index):
if index.isValid():
item = index.internalPointer()
if item:
return item.obj
return self.rootItem
But if I add this layer of metadata, selecting and editing items does not work anymore. I can still select cells in first colum of root items and edit all items in the first column. But eg. selecting sub-items or items in the second column doesn't work anymore!
I've noticed that it works if I store my TempContainer inside the corresponding TreeItem, but this is not really an option as there are a multiple containers that point to the same item in my full code.
I'm running python 3.5.3 under Linux (Fedora 24), with PyQt 5.6.2 (I've also tried 5.8.2) and Qt 5.6.2.
[1] Unfortunately, I cannot attach a ZIP of the full source, but the code is available under https://www.riverbankcomputing.com/software/pyqt/download5, in /examples/itemviews/editabletreemodel
The full source of the modified editabletreemodel.py:
(Note that I only modified a few lines and that you will still need the other files from the example to run this.)
#!/usr/bin/env python
#############################################################################
##
## Copyright (C) 2013 Riverbank Computing Limited.
## Copyright (C) 2010 Nokia Corporation and/or its subsidiary(-ies).
## All rights reserved.
##
## This file is part of the examples of PyQt.
##
## $QT_BEGIN_LICENSE:BSD$
## You may use this file under the terms of the BSD license as follows:
##
## "Redistribution and use in source and binary forms, with or without
## modification, are permitted provided that the following conditions are
## met:
## * Redistributions of source code must retain the above copyright
## notice, this list of conditions and the following disclaimer.
## * Redistributions in binary form must reproduce the above copyright
## notice, this list of conditions and the following disclaimer in
## the documentation and/or other materials provided with the
## distribution.
## * Neither the name of Nokia Corporation and its Subsidiary(-ies) nor
## the names of its contributors may be used to endorse or promote
## products derived from this software without specific prior written
## permission.
##
## THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
## "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
## LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
## A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
## OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
## SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
## LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
## DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
## THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
## (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
## OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE."
## $QT_END_LICENSE$
##
#############################################################################
from PyQt5.QtCore import (QAbstractItemModel, QFile, QIODevice,
QItemSelectionModel, QModelIndex, Qt)
from PyQt5.QtWidgets import QApplication, QMainWindow
import editabletreemodel_rc
from ui_mainwindow import Ui_MainWindow
class TreeItem(object):
def __init__(self, data, parent=None):
self.parentItem = parent
self.itemData = data
self.childItems = []
def child(self, row):
return self.childItems[row]
def childCount(self):
return len(self.childItems)
def childNumber(self):
if self.parentItem != None:
return self.parentItem.childItems.index(self)
return 0
def columnCount(self):
return len(self.itemData)
def data(self, column):
return self.itemData[column]
def insertChildren(self, position, count, columns):
if position < 0 or position > len(self.childItems):
return False
for row in range(count):
data = [None for v in range(columns)]
item = TreeItem(data, self)
self.childItems.insert(position, item)
return True
def insertColumns(self, position, columns):
if position < 0 or position > len(self.itemData):
return False
for column in range(columns):
self.itemData.insert(position, None)
for child in self.childItems:
child.insertColumns(position, columns)
return True
def parent(self):
return self.parentItem
def removeChildren(self, position, count):
if position < 0 or position + count > len(self.childItems):
return False
for row in range(count):
self.childItems.pop(position)
return True
def removeColumns(self, position, columns):
if position < 0 or position + columns > len(self.itemData):
return False
for column in range(columns):
self.itemData.pop(position)
for child in self.childItems:
child.removeColumns(position, columns)
return True
def setData(self, column, value):
if column < 0 or column >= len(self.itemData):
return False
self.itemData[column] = value
return True
class TempContainer:
def __init__(self, name, obj):
self.name = name
self.obj = obj
class TreeModel(QAbstractItemModel):
def __init__(self, headers, data, parent=None):
super(TreeModel, self).__init__(parent)
rootData = [header for header in headers]
self.rootItem = TreeItem(rootData)
self.setupModelData(data.split("\n"), self.rootItem)
self.tmpcache = []
def columnCount(self, parent=QModelIndex()):
return self.rootItem.columnCount()
def data(self, index, role):
if not index.isValid():
return None
if role != Qt.DisplayRole and role != Qt.EditRole:
return None
item = self.getItem(index)
return item.data(index.column())
def flags(self, index):
if not index.isValid():
return 0
return Qt.ItemIsEditable | Qt.ItemIsEnabled | Qt.ItemIsSelectable
def getItem(self, index):
if index.isValid():
item = index.internalPointer()
if item:
return item.obj
return self.rootItem
def headerData(self, section, orientation, role=Qt.DisplayRole):
if orientation == Qt.Horizontal and role == Qt.DisplayRole:
return self.rootItem.data(section)
return None
def index(self, row, column, parent=QModelIndex()):
if parent.isValid() and parent.column() != 0:
return QModelIndex()
parentItem = self.getItem(parent)
childItem = parentItem.child(row)
if childItem:
o = TempContainer("child", childItem)
self.tmpcache.append(o)
return self.createIndex(row, column, o)
else:
return QModelIndex()
def insertColumns(self, position, columns, parent=QModelIndex()):
self.beginInsertColumns(parent, position, position + columns - 1)
success = self.rootItem.insertColumns(position, columns)
self.endInsertColumns()
return success
def insertRows(self, position, rows, parent=QModelIndex()):
parentItem = self.getItem(parent)
self.beginInsertRows(parent, position, position + rows - 1)
success = parentItem.insertChildren(position, rows,
self.rootItem.columnCount())
self.endInsertRows()
return success
def parent(self, index):
if not index.isValid():
return QModelIndex()
childItem = self.getItem(index)
parentItem = childItem.parent()
if parentItem == self.rootItem:
return QModelIndex()
o = TempContainer("parent", parentItem)
self.tmpcache.append(o)
return self.createIndex(parentItem.childNumber(), 0, o)
def removeColumns(self, position, columns, parent=QModelIndex()):
self.beginRemoveColumns(parent, position, position + columns - 1)
success = self.rootItem.removeColumns(position, columns)
self.endRemoveColumns()
if self.rootItem.columnCount() == 0:
self.removeRows(0, self.rowCount())
return success
def removeRows(self, position, rows, parent=QModelIndex()):
parentItem = self.getItem(parent)
self.beginRemoveRows(parent, position, position + rows - 1)
success = parentItem.removeChildren(position, rows)
self.endRemoveRows()
return success
def rowCount(self, parent=QModelIndex()):
parentItem = self.getItem(parent)
return parentItem.childCount()
def setData(self, index, value, role=Qt.EditRole):
if role != Qt.EditRole:
return False
item = self.getItem(index)
result = item.setData(index.column(), value)
if result:
self.dataChanged.emit(index, index)
return result
def setHeaderData(self, section, orientation, value, role=Qt.EditRole):
if role != Qt.EditRole or orientation != Qt.Horizontal:
return False
result = self.rootItem.setData(section, value)
if result:
self.headerDataChanged.emit(orientation, section, section)
return result
def setupModelData(self, lines, parent):
parents = [parent]
indentations = [0]
number = 0
while number < len(lines):
position = 0
while position < len(lines[number]):
if lines[number][position] != " ":
break
position += 1
lineData = lines[number][position:].trimmed()
if lineData:
# Read the column data from the rest of the line.
columnData = [s for s in lineData.split('\t') if s]
if position > indentations[-1]:
# The last child of the current parent is now the new
# parent unless the current parent has no children.
if parents[-1].childCount() > 0:
parents.append(parents[-1].child(parents[-1].childCount() - 1))
indentations.append(position)
else:
while position < indentations[-1] and len(parents) > 0:
parents.pop()
indentations.pop()
# Append a new item to the current parent's list of children.
parent = parents[-1]
parent.insertChildren(parent.childCount(), 1,
self.rootItem.columnCount())
for column in range(len(columnData)):
parent.child(parent.childCount() -1).setData(column, columnData[column])
number += 1
class MainWindow(QMainWindow, Ui_MainWindow):
def __init__(self, parent=None):
super(MainWindow, self).__init__(parent)
self.setupUi(self)
headers = ("Title", "Description")
file = QFile(':/default.txt')
file.open(QIODevice.ReadOnly)
model = TreeModel(headers, file.readAll())
file.close()
self.view.setModel(model)
for column in range(model.columnCount()):
self.view.resizeColumnToContents(column)
self.exitAction.triggered.connect(QApplication.instance().quit)
self.view.selectionModel().selectionChanged.connect(self.updateActions)
self.actionsMenu.aboutToShow.connect(self.updateActions)
self.insertRowAction.triggered.connect(self.insertRow)
self.insertColumnAction.triggered.connect(self.insertColumn)
self.removeRowAction.triggered.connect(self.removeRow)
self.removeColumnAction.triggered.connect(self.removeColumn)
self.insertChildAction.triggered.connect(self.insertChild)
self.updateActions()
def insertChild(self):
index = self.view.selectionModel().currentIndex()
model = self.view.model()
if model.columnCount(index) == 0:
if not model.insertColumn(0, index):
return
if not model.insertRow(0, index):
return
for column in range(model.columnCount(index)):
child = model.index(0, column, index)
model.setData(child, "[No data]", Qt.EditRole)
if model.headerData(column, Qt.Horizontal) is None:
model.setHeaderData(column, Qt.Horizontal, "[No header]",
Qt.EditRole)
self.view.selectionModel().setCurrentIndex(model.index(0, 0, index),
QItemSelectionModel.ClearAndSelect)
self.updateActions()
def insertColumn(self):
model = self.view.model()
column = self.view.selectionModel().currentIndex().column()
changed = model.insertColumn(column + 1)
if changed:
model.setHeaderData(column + 1, Qt.Horizontal, "[No header]",
Qt.EditRole)
self.updateActions()
return changed
def insertRow(self):
index = self.view.selectionModel().currentIndex()
model = self.view.model()
if not model.insertRow(index.row()+1, index.parent()):
return
self.updateActions()
for column in range(model.columnCount(index.parent())):
child = model.index(index.row()+1, column, index.parent())
model.setData(child, "[No data]", Qt.EditRole)
def removeColumn(self):
model = self.view.model()
column = self.view.selectionModel().currentIndex().column()
changed = model.removeColumn(column)
if changed:
self.updateActions()
return changed
def removeRow(self):
index = self.view.selectionModel().currentIndex()
model = self.view.model()
if (model.removeRow(index.row(), index.parent())):
self.updateActions()
def updateActions(self):
hasSelection = not self.view.selectionModel().selection().isEmpty()
self.removeRowAction.setEnabled(hasSelection)
self.removeColumnAction.setEnabled(hasSelection)
hasCurrent = self.view.selectionModel().currentIndex().isValid()
self.insertRowAction.setEnabled(hasCurrent)
self.insertColumnAction.setEnabled(hasCurrent)
if hasCurrent:
self.view.closePersistentEditor(self.view.selectionModel().currentIndex())
row = self.view.selectionModel().currentIndex().row()
column = self.view.selectionModel().currentIndex().column()
if self.view.selectionModel().currentIndex().parent().isValid():
self.statusBar().showMessage("Position: (%d,%d)" % (row, column))
else:
self.statusBar().showMessage("Position: (%d,%d) in top level" % (row, column))
if __name__ == '__main__':
import sys
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec_())
EDIT:
I realize the example I gave is rather pointless, TempContainer serves no real purpose! It is just a minimal example that shows the problem I ran into. As to why TempContainer is required, suppose your actual TreeItems looks like this:
class TreeItem:
def __init__(self,
name:str, description:str,
data:List[TreeItem], metadata:Dict[str,str],
parent:TreeItem=None):
...
Which should be rendered in the TableView as:
itemA somedescription here
itemB this is another item
\--metadata
| \--created yesterday
| \--color green
\--data
\--itemC this is a sub-item of itemB
| \--metadata
| \--data
\--itemD and another item
Then you would need an index to e.g. 'name of the second metadata item of itemB'. You could create some sort of shadow-tree which reflects this structure but consists of normal TreeItems, but this might get cumbersome, especially with editing. My idea was to store in the index not only a pointer to a TreeItem but also annotate it with a sub-index, indicating which part of a TreeItem we're pointing at.
The indexes you are creating are almost OK, I think, but they have to fulfil a little bit more of the contract. Specifically they need to support a valid equals method. In your model index method you create a new TempContainer inside the index. That means that when the view compares that index with another one it has earlier requested for the same place in the tree, then they won't compare equal. That's because it's using the default equals on the QModelIndex class which compares row, column, and internalPointer for equality. The docs say:
All values in the model index are used when comparing with another
model index
While your TempContainer idea seems initially attractive as a way of adapting your tree to the view, this is not easy to do, as you are finding. Qt essentially expects the model to have a clear tree structure matching that in the view, and it's difficult to avoid. In cases like this I have always created a tree layer of Python objects whose references I use as the internalPointer.

PyQt4 force view to fetchMore from QAbstractItemModel

I have a QTableView that dynamically loads data from a custom model that inherits QAbstractItemModel. The model implements both fetchMore and canFetchMore.
The problem is that I would like to be able to select all rows for small datasets, but if I hit ctrl-a in the view it only will select the rows that are currently loaded.
Is there some mechanism to force the QTableView to fetch more rows? Ideally I would like to show a progress bar indicating the fraction of data that has been loaded from the model. Every few seconds I would like to force the model to load a bit more of the data, but I still want to let the user interact with the data that has been loaded so far. This way when the progress bar is complete the user can press ctrl-a and be confident that all data is selected.
Edit: I have another motivating use case. I want to jump to a specific row, but if that row is not loaded my interface does nothing.
How can I force a QAbstractItemModel to fetch more (or up to a specific row) and then force the QTableView to show it?
If I don't implement fetchMore and canFetchMore, the previous functionality works, but loading the tables is very slow. When I implement those methods the opposite happens. Not having an answer to this problem is causing issues with the usability of my qt interface, so I'm opening a bounty for this question.
Here is a method I'm using to select a specific row.
def select_row_from_id(view, _id, scroll=False, collapse=True):
"""
_id is from the iders function (i.e. an ibeis rowid)
selects the row in that view if it exists
"""
with ut.Timer('[api_item_view] select_row_from_id(id=%r, scroll=%r, collapse=%r)' %
(_id, scroll, collapse)):
qtindex, row = view.get_row_and_qtindex_from_id(_id)
if row is not None:
if isinstance(view, QtWidgets.QTreeView):
if collapse:
view.collapseAll()
select_model = view.selectionModel()
select_flag = QtCore.QItemSelectionModel.ClearAndSelect
#select_flag = QtCore.QItemSelectionModel.Select
#select_flag = QtCore.QItemSelectionModel.NoUpdate
with ut.Timer('[api_item_view] selecting name. qtindex=%r' % (qtindex,)):
select_model.select(qtindex, select_flag)
with ut.Timer('[api_item_view] expanding'):
view.setExpanded(qtindex, True)
else:
# For Table Views
view.selectRow(row)
# Scroll to selection
if scroll:
with ut.Timer('scrolling'):
view.scrollTo(qtindex)
return row
return None
If the user has manually scrolled past the row in question then this function works. However, if the user has not seen the specific row this function just scrolls back to the top of the view.
It's probably too late for the answer here but maybe it would still benefit someone in future.
Below one can find a working example of a list model with canFetchMore and fetchMore methods + a view with a couple of custom methods:
Method trying to load more items from the model, if the model has something not loaded yet
Method capable of fetching the specific rows from the model if they haven't been loaded yet
The QMainWindow subclass in the example has a timer which is used to repeatedly call the first of the above mentioned methods, each time forcing the load of another batch of items from the model into the view. The loading of items in batches over small time intervals allows one to avoid blocking the UI thread completely and be able to edit the items loaded so far with little to no lag. The example contains a progress bar showing the part of items loaded so far.
The QMainWindow subclass also has a spin box which allows one to pick a particular row to show in the view. If the corresponding item has already been fetched from the model, the view simply scrolls to it. Otherwise it fetches this row's item from the model first, in a synchronous i.e. UI blocking fashion.
Here's the full code of the solution, tested with python 3.5.2 and PyQt5:
import sys
from PyQt5 import QtWidgets, QtCore
class DelayedFetchingListModel(QtCore.QAbstractListModel):
def __init__(self, batch_size=100, max_num_nodes=1000):
QtCore.QAbstractListModel.__init__(self)
self.batch_size = batch_size
self.nodes = []
for i in range(0, self.batch_size):
self.nodes.append('node ' + str(i))
self.max_num_nodes = max(self.batch_size, max_num_nodes)
def flags(self, index):
if not index.isValid():
return QtCore.Qt.ItemIsEnabled
return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEditable;
def rowCount(self, index):
if index.isValid():
return 0
return len(self.nodes)
def data(self, index, role):
if not index.isValid():
return None
if role != QtCore.Qt.DisplayRole:
return None
row = index.row()
if row < 0 or row >= len(self.nodes):
return None
else:
return self.nodes[row]
def setData(self, index, value, role):
if not index.isValid():
return False
if role != QtCore.Qt.EditRole:
return False
row = index.row()
if row < 0 or row >= len(self.nodes):
return False
self.nodes[row] = value
self.dataChanged.emit(index, index)
return True
def headerData(self, section, orientation, role):
if section != QtCore.Qt.Horizontal:
return None
if section != 0:
return None
if role != QtCore.Qt.DisplayRole:
return None
return 'node'
def canFetchMore(self, index):
if index.isValid():
return False
return (len(self.nodes) < self.max_num_nodes)
def fetchMore(self, index):
if index.isValid():
return
current_len = len(self.nodes)
target_len = min(current_len + self.batch_size, self.max_num_nodes)
self.beginInsertRows(index, current_len, target_len - 1)
for i in range(current_len, target_len):
self.nodes.append('node ' + str(i))
self.endInsertRows()
class ListView(QtWidgets.QListView):
def __init__(self, parent=None):
QtWidgets.QListView.__init__(self, parent)
def jumpToRow(self, row):
model = self.model()
if model == None:
return False
num_rows = model.rowCount()
while(row >= num_rows):
res = fetchMoreRows(QtCore.QModelIndex())
if res == False:
return False
num_rows = model.rowCount()
index = model.index(row, 0, QtCore.QModelIndex())
self.scrollTo(index, QtCore.QAbstractItemView.PositionAtCenter)
return True
def fetchMoreRows(self, index):
model = self.model()
if model == None:
return False
if not model.canFetchMore(index):
return False
model.fetchMore(index)
return True
class MainForm(QtWidgets.QMainWindow):
def __init__(self, parent=None):
QtWidgets.QMainWindow.__init__(self, parent)
# Setup the model
self.max_num_nodes = 10000
self.batch_size = 100
self.model = DelayedFetchingListModel(batch_size=self.batch_size, max_num_nodes=self.max_num_nodes)
# Setup the view
self.view = ListView()
self.view.setModel(self.model)
# Update the currently selected row in the spinbox
self.view.selectionModel().currentChanged.connect(self.onCurrentItemChanged)
# Select the first row in the model
index = self.model.index(0, 0, QtCore.QModelIndex())
self.view.selectionModel().clearSelection()
self.view.selectionModel().select(index, QtCore.QItemSelectionModel.Select)
# Setup the spinbox
self.spinBox = QtWidgets.QSpinBox()
self.spinBox.setMinimum(0)
self.spinBox.setMaximum(self.max_num_nodes-1)
self.spinBox.setSingleStep(1)
self.spinBox.valueChanged.connect(self.onSpinBoxNewValue)
# Setup the progress bar showing the status of model data loading
self.progressBar = QtWidgets.QProgressBar()
self.progressBar.setRange(0, self.max_num_nodes)
self.progressBar.setValue(0)
self.progressBar.valueChanged.connect(self.onProgressBarValueChanged)
# Add status bar but initially hidden, will only show it if there's something to say
self.statusBar = QtWidgets.QStatusBar()
self.statusBar.hide()
# Collect all this stuff into a vertical layout
self.layout = QtWidgets.QVBoxLayout()
self.layout.addWidget(self.view)
self.layout.addWidget(self.spinBox)
self.layout.addWidget(self.progressBar)
self.layout.addWidget(self.statusBar)
self.window = QtWidgets.QWidget()
self.window.setLayout(self.layout)
self.setCentralWidget(self.window)
# Setup timer to fetch more data from the model over small time intervals
self.timer = QtCore.QBasicTimer()
self.timerPeriod = 1000
self.timer.start(self.timerPeriod, self)
def onCurrentItemChanged(self, current, previous):
if not current.isValid():
return
row = current.row()
self.spinBox.setValue(row)
def onSpinBoxNewValue(self, value):
try:
value_int = int(value)
except ValueError:
return
num_rows = self.model.rowCount(QtCore.QModelIndex())
if value_int >= num_rows:
# There is no such row within the model yet, trying to fetch more
while(True):
res = self.view.fetchMoreRows(QtCore.QModelIndex())
if res == False:
# We shouldn't really get here in this example since out
# spinbox's range is limited by exactly the number of items
# possible to fetch but generally it's a good idea to handle
# cases like this, when someone requests more rows than
# the model has
self.statusBar.show()
self.statusBar.showMessage("Can't jump to row %d, the model has only %d rows" % (value_int, self.model.rowCount(QtCore.QModelIndex())))
return
num_rows = self.model.rowCount(QtCore.QModelIndex())
if value_int < num_rows:
break;
if num_rows < self.max_num_nodes:
# If there are still items to fetch more, check if we need to update the progress bar
if self.progressBar.value() < value_int:
self.progressBar.setValue(value_int)
elif num_rows == self.max_num_nodes:
# All items are loaded, nothing to fetch more -> no need for the progress bar
self.progressBar.hide()
# Update the selection accordingly with the new row and scroll to it
index = self.model.index(value_int, 0, QtCore.QModelIndex())
selectionModel = self.view.selectionModel()
selectionModel.clearSelection()
selectionModel.select(index, QtCore.QItemSelectionModel.Select)
self.view.scrollTo(index, QtWidgets.QAbstractItemView.PositionAtCenter)
# Ensure the status bar is hidden now
self.statusBar.hide()
def timerEvent(self, event):
res = self.view.fetchMoreRows(QtCore.QModelIndex())
if res == False:
self.timer.stop()
else:
self.progressBar.setValue(self.model.rowCount(QtCore.QModelIndex()))
if not self.timer.isActive():
self.timer.start(self.timerPeriod, self)
def onProgressBarValueChanged(self, value):
if value >= self.max_num_nodes:
self.progressBar.hide()
def main():
app = QtWidgets.QApplication(sys.argv)
form = MainForm()
form.show()
app.exec_()
if __name__ == '__main__':
main()
One more thing I'd like to note is that this example expects the fetchMore method to do its work synchronously. But in more sophisticated approaches fetchMore doesn't actually have to act so. If your model loads its items from, say, a database then talking with the database synchronously in the UI thread would be a bad idea. Instead fetchMore implementation could start the asynchronous sequence of signal/slot communications with some object handling the communication with the database occurring in some background thread.
a self-using model class, based on Dmitry's answer.
class EzQListModel(QAbstractListModel):
items_changed = Signal()
an_item_changed = Signal(int)
def __init__(self, batch_size=100, items_header='Items', parent=None):
super().__init__(parent)
self._batch_size = batch_size
self._current_size = 0
self._items = []
self.items_header = items_header
self.data_getter_mapping = {Qt.DisplayRole: self.get_display_data, Qt.BackgroundRole: self.get_background_data}
#property
def items_size(self):
return len(self._items)
def update_fetch_more(self):
if self.canFetchMore():
self.fetchMore()
return self
#contextlib.contextmanager
def ctx_change_items(self):
yield
self.items_changed.emit()
#contextlib.contextmanager
def ctx_change_an_item(self, index):
yield
self.an_item_changed.emit(index)
def clear_items(self):
with self.ctx_change_items():
self._items.clear()
self._current_size = 0
return self
def append_item(self, x):
with self.ctx_change_items():
self._items.append(x)
return self
def insert_item(self, index, x):
with self.ctx_change_items():
self._items.insert(index, x)
return self
def extend_items(self, items):
with self.ctx_change_items():
self._items.extend(items)
return self
def get_item(self, index):
return self._items[index]
def set_item(self, index, value):
with self.ctx_change_items():
with self.ctx_change_an_item(index):
self._items[index] = value
return self
def flags(self, index):
if not index.isValid():
return Qt.ItemIsEnabled
return Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable
def rowCount(self, parent=QModelIndex()):
if parent.isValid():
return 0
n = self._current_size
if n <= self.items_size:
return n
else:
self._current_size = self.items_size
return self.items_size
#staticmethod
def get_none_data(index):
return None
def get_display_data(self, index: QModelIndex):
return self._items[index.row()]
#staticmethod
def get_background_data(index: QModelIndex):
palette = QApplication.palette()
return palette.alternateBase() if index.row() % 2 else palette.base()
def data(self, index, role=Qt.DisplayRole):
if not index.isValid():
return None
if self.items_size <= index.row() < 0:
return None
return self.data_getter_mapping.get(role, self.get_none_data)(index)
def setData(self, index, value, role=Qt.EditRole):
if not index.isValid():
return False
if role != Qt.EditRole:
return False
row = index.row()
if self.items_size <= row < 0:
return False
self._items[row] = value
self.dataChanged.emit(index, index)
# print(self.setData.__name__, row, self._items[row], self.data(index))
return True
def headerData(self, section, orientation, role=None):
if orientation != Qt.Horizontal:
return None
if section != 0:
return None
if role != Qt.DisplayRole:
return None
return self.items_header
def canFetchMore(self, parent: QModelIndex = QModelIndex()):
if parent.isValid():
return False
return self._current_size < self.items_size
def fetchMore(self, parent: QModelIndex = QModelIndex()):
if parent.isValid():
return
fcls = FirstCountLastStop().set_first_and_total(self._current_size,
min(self.items_size - self._current_size, self._batch_size))
self.beginInsertRows(parent, fcls.first, fcls.last)
self.endInsertRows()
self._current_size += fcls.total
class FirstCountLastStop:
def __init__(self):
self.first = 0
self.total = 0
self.last = 0
self.stop = 1
def set_first_and_total(self, first, count):
self.first = first
self.total = count
self.stop = first + count
self.last = self.stop - 1
return self

Drag and Dropping Rows between two separate QTableWidgets

Background:
I have two separate QTableWidgets on my Main Window. Both of which are dynamically added in python. I have implemented pyqt code, which can be found under three_pineapples solution here.
My Problem
I want to be able to drag and drop table rows from one table, to another. For example:
If I attempt to drag Item B from the Left Table into the Right Table
This would be the outcome
Any suggestions on how to get this desired behavior?
I took a look at the #three_pineapples' code, didn't understand a half and deleted that half. Works with multiple row selection.
import sys
from PyQt4.QtGui import *
class TableWidgetDragRows(QTableWidget):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setDragEnabled(True)
self.setAcceptDrops(True)
self.setSelectionBehavior(QAbstractItemView.SelectRows)
self.setDragDropOverwriteMode(False)
# self.setSelectionMode(QAbstractItemView.SingleSelection)
self.last_drop_row = None
# Override this method to get the correct row index for insertion
def dropMimeData(self, row, col, mimeData, action):
self.last_drop_row = row
return True
def dropEvent(self, event):
# The QTableWidget from which selected rows will be moved
sender = event.source()
# Default dropEvent method fires dropMimeData with appropriate parameters (we're interested in the row index).
super().dropEvent(event)
# Now we know where to insert selected row(s)
dropRow = self.last_drop_row
selectedRows = sender.getselectedRowsFast()
# Allocate space for transfer
for _ in selectedRows:
self.insertRow(dropRow)
# if sender == receiver (self), after creating new empty rows selected rows might change their locations
sel_rows_offsets = [0 if self != sender or srow < dropRow else len(selectedRows) for srow in selectedRows]
selectedRows = [row + offset for row, offset in zip(selectedRows, sel_rows_offsets)]
# copy content of selected rows into empty ones
for i, srow in enumerate(selectedRows):
for j in range(self.columnCount()):
item = sender.item(srow, j)
if item:
source = QTableWidgetItem(item)
self.setItem(dropRow + i, j, source)
# delete selected rows
for srow in reversed(selectedRows):
sender.removeRow(srow)
event.accept()
def getselectedRowsFast(self):
selectedRows = []
for item in self.selectedItems():
if item.row() not in selectedRows:
selectedRows.append(item.row())
selectedRows.sort()
return selectedRows
class Window(QWidget):
def __init__(self):
super().__init__()
layout = QHBoxLayout()
self.setLayout(layout)
self.table_widgets = []
for _ in range(3):
tw = TableWidgetDragRows()
tw.setColumnCount(2)
tw.setHorizontalHeaderLabels(['Colour', 'Model'])
self.table_widgets.append(tw)
layout.addWidget(tw)
filled_widget = self.table_widgets[0]
items = [('Red', 'Toyota'), ('Blue', 'RV'), ('Green', 'Beetle')]
for i, (colour, model) in enumerate(items):
c = QTableWidgetItem(colour)
m = QTableWidgetItem(model)
filled_widget.insertRow(filled_widget.rowCount())
filled_widget.setItem(i, 0, c)
filled_widget.setItem(i, 1, m)
if __name__ == '__main__':
app = QApplication(sys.argv)
window = Window()
window.show()
sys.exit(app.exec_())

Categories

Resources