Re: General question about Proxy Model
"John F Sturtz" <[email protected]>
| Newsgroups | gmane.comp.python.pyqt-pykde |
|---|---|
| Message-ID | <[email protected]> |
Hi again Maurizio. Thanks as always for your help. Minimal example is attached. Here is a brief summary of what it is trying to do: This is code to manage a list of categories and subcategories. It creates a dialog (Dlg, which is loaded from the attached dlg.ui file) that uses a QTreeView to display a list of main categories, each of which can contain zero or more subcategories. The dialog has buttons to create a new main category, create a new subcategory, and delete either type. The actual data for a main category or subcategory is represented by a CategoryNodeobject. A tree structure of these nodes is the data structure that underlies the source model (CategoryTreeModel). The source model manipulates the data tree using the methods defined in the CategoryNode class. The QTreeView (TreeView) uses a QSortFilterProxyModel proxy model to maintain a sorted view of the data. Here's how I maintain the sorted view (not sure this is correct, but it's what I gleaned from such documentation as I could find): The proxy model overrides setData(), so when an item in the view is edited (either an existing one is changed, or a new one is added), the proxy's setData() gets called. It first calls the source model's setData() to update the appropriate node in the underlyingCategoryNode tree. Then it calls sort() and invalidate() on itself to sort the view. All this seems to work pretty well. I've ensured that the model is implemented correctly at least to this extent: * I've run the model through the pytest qmodeltester and not gotten any warnings. * I've spent quite a bit of time going about adding, deleting and changing the descriptions of the items in the view using the buttons in the dialog, and they seem to work properly. The problem comes when I try to do this: When you add either a main category or subcategory, it creates a new node in the tree with a blank value, then opens a QLineEdit delegate editor to enter the description. If the user hits Escape, or hits Enter without typing in a value, I want to just delete the newly-added node and throw it away. To that end, I override closeEditor() in the TreeView. I get the view's currentIndex() (which should be an index in the proxy model, I think?). If the value is empty, I get a reference to the source model, map the current index to the corresponding index in the source model (mapToSource()), and then call the source model's removeRows() method to delete it (lines 277-282). This consistently crashes or produces a warning. On my machine, the following consistently crashes: * Start the app * Click on Reimbursible at the bottom of the list * Click the Sub button to add a new empty subcategory * Hit Escape This consistently produces the message QSortFilterProxyModel: inconsistent changes reported by source model (but doesn't crash): * Start the app * Click on Healthcare * Click the Sub button to add a new empty subcategory * Hit Escape I thought I was doing all this correctly (don't we usually?), but clearly I must misunderstand some part of it. If you (or anyone) spots a problem, I'd be grateful to hear it. Thanks! /John On 5/17/2019 4:37:47 PM, Maurizio Berti <[email protected]> wrote: Since you're implementing your own model, implementing removeRows() in the source model should be enough, but you have to ensure that its implementation responds as expected: removeRows has to return a bool, and the parent *must* be checked, expecially if you have a tree structure. Also ensure that all basic model functions (index, parent, rowCount, columnCount and data) are correctly implemented. I'd suggest you to give us a minimal example anyway, you might even find out what is wrong in your case. Maurizio Il giorno mar 14 mag 2019 alle ore 21:38 John F Sturtz <[email protected] [mailto:[email protected]]> ha scritto: Hi again. I'm the one who started the QTreeView + sort + delete = crash thread on May 8th. In response to assistance from Kyle and Florian, I've re-implemented to use a QAbstractItemModel with associated QSortFilterProxyModel. On the whole, that seems to be working pretty well, but there's one thing (well, at least one) I haven't been clear on despite poring over what documentation I can find. I can post the test code again if it will help, but I think this is more of a general question: If I have both a source model and also a proxy, and I'm modifying the view in a way that changes the structure (deleting a row, in the current case I'm working on), should I be overriding removeRows() for both the source model and the proxy, and calling beginRemoveRows()/endRemoveRows() in both cases? I guess that seems to make sense to me, and my initial testing suggests it's so, as it seems to work (whereas if I don't do that, I either get a crash, or a message something like QSortFilterProxyModel: inconsistent changes reported by source model). Same for inserting rows? Just checking to see if I'm thinking right here. Thanks! /John _______________________________________________ PyQt mailing list [email protected] [mailto:[email protected]] https://www.riverbankcomputing.com/mailman/listinfo/pyqt [https://www.riverbankcomputing.com/mailman/listinfo/pyqt] -- È difficile avere una convinzione precisa quando si parla delle ragioni del cuore. - "Sostiene Pereira", Antonio Tabucchi http://www.jidesk.net [http://www.jidesk.net] _______________________________________________ PyQt mailing list [email protected] https://www.riverbankcomputing.com/mailman/listinfo/pyqt
cat.py
(text/plain, 15.7 KB)
from PyQt5 import QtCore, QtWidgets, QtGui
from PyQt5 import uic
from itertools import groupby
import sys
# ------------------------------------------------------------------------------
# | CategoryNode |
# | |
# | |
# | |
# | Defines the nodes of the data tree that underlies CategoryTreeModel |
# ------------------------------------------------------------------------------
class CategoryNode:
def __init__(self, desc, parent=None):
self.parent_node = parent
self._desc = desc
self.children = []
# ----------------------------------------------------------------------
# | CategoryNode accessor functions
# ----------------------------------------------------------------------
# Number of columns is always one in this model
@staticmethod
def n_columns():
return 1
# Return category description
def desc(self):
return self._desc
# Set category description
def set_desc(self, desc):
self._desc = desc
# Return row
def row(self):
if self.parent_node:
return self.parent_node.children.index(self)
else:
return 0
# Return parent
def parent(self):
return self.parent_node
# Add a child
def append_child(self, c):
self.children.append(c)
# Return child from given row
def child(self, row):
return self.children[row]
# Return number of children
def n_children(self):
return len(self.children)
# Delete child from given row
def del_child(self, row):
del self.children[row]
# ----
# Printable representation
def __repr__(self):
return str(f'->{self._desc}')
# Recursively dump tree contents
def dump(self, i=0):
print(f'''{(' ' * (i * 4))} {self.row()} -> {self}''')
for c in self.children:
c.dump(i+1)
# ------------------------------------------------------------------------------
# | CategoryTreeModel |
# | |
# | |
# | |
# | Model used by CategoryDialog tree view |
# ------------------------------------------------------------------------------
class CategoryTreeModel(QtCore.QAbstractItemModel):
# ------------------------------------------------------------[override]
# | __init__()
# ----------------------------------------------------------------------
def __init__(self, category_data, parent=None):
super().__init__(parent)
# Create root node
self.root = CategoryNode('Category')
# Create main category/subcategory node structure
for g in groupby(category_data, lambda rec: rec[1]):
sub_list = list(g[1])
main_node = CategoryNode(g[0], self.root)
self.root.append_child(main_node)
for s in sub_list:
if s[2] is not None:
sub_node = CategoryNode(s[2], main_node)
main_node.append_child(sub_node)
# ------------------------------------------------------------[override]
# | rowCount()
# ----------------------------------------------------------------------
def rowCount(self, parent=QtCore.QModelIndex()):
if parent.isValid():
return parent.internalPointer().n_children()
else:
return self.root.n_children()
# ------------------------------------------------------------[override]
# | columnCount()
# ----------------------------------------------------------------------
def columnCount(self, parent=QtCore.QModelIndex()):
if parent.isValid():
return parent.internalPointer().n_columns()
else:
return self.root.n_columns()
# ------------------------------------------------------------[override]
# | data()
# ----------------------------------------------------------------------
def data(self, index, role=QtCore.Qt.DisplayRole):
if index.isValid():
if role == QtCore.Qt.DisplayRole:
return index.internalPointer().desc()
elif role == QtCore.Qt.FontRole:
font = QtGui.QFont('Candara')
font.setPointSize(11)
return font
elif role == QtCore.Qt.ForegroundRole:
color = '#1e62ce'
return QtGui.QBrush(QtGui.QColor(color))
return None
# ------------------------------------------------------------[override]
# | flags()
# ----------------------------------------------------------------------
def flags(self, index):
if index.isValid():
return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsEditable
else:
return QtCore.Qt.NoItemFlags
# ------------------------------------------------------------[override]
# | index()
# ----------------------------------------------------------------------
def index(self, row, column, parent=QtCore.QModelIndex()):
# According to legend, index() may sometimes be called with invalid parameters
# (that is, with parameters that don't actually have an index). So this is
# necessary to keep it from crashing.
# https://stackoverflow.com/questions/26680168/pyqt-treeview-index-error-removing-last-row
if not self.hasIndex(row, column, parent):
return QtCore.QModelIndex()
elif parent.isValid():
return self.createIndex(row, column, parent.internalPointer().child(row))
else:
return self.createIndex(row, column, self.root.child(row))
# ------------------------------------------------------------[override]
# | parent()
# ----------------------------------------------------------------------
def parent(self, index):
if not index.isValid():
return QtCore.QModelIndex()
child_node = index.internalPointer()
parent_node = child_node.parent()
if parent_node == self.root:
r = QtCore.QModelIndex()
else:
r = self.createIndex(parent_node.row(), 0, parent_node)
return r
# ------------------------------------------------------------[override]
# | setData()
# ----------------------------------------------------------------------
def setData(self, index, value, role=QtCore.Qt.EditRole):
if role == QtCore.Qt.EditRole:
# Set value in data tree node
node = index.internalPointer()
node.set_desc(value)
return True
# ------------------------------------------------------------[override]
# | insertRows()
# ----------------------------------------------------------------------
def insertRows(self, row, rows, parent=QtCore.QModelIndex()):
assert rows == 1
# Determine parent
if parent.internalPointer() is None:
parent_node = self.root
else:
parent_node = parent.internalPointer()
# Create new data tree node and add it to parent
new = CategoryNode(None, parent_node)
self.beginInsertRows(parent, row, row + rows - 1)
parent_node.append_child(new)
self.endInsertRows()
return True
# ------------------------------------------------------------[override]
# | removeRows()
# ----------------------------------------------------------------------
def removeRows(self, row, rows, parent=QtCore.QModelIndex()):
assert rows == 1
# Determine parent
if parent.internalPointer() is None:
parent_node = self.root
else:
parent_node = parent.internalPointer()
# Delete it from the parent
self.beginRemoveRows(parent, row, row + rows - 1)
parent_node.del_child(row)
self.endRemoveRows()
return True
# ------------------------------------------------------------------------------
# | Proxy |
# | |
# | |
# | |
# | QSortFilterProxyModel object |
# ------------------------------------------------------------------------------
class Proxy(QtCore.QSortFilterProxyModel):
# ------------------------------------------------------------[override]
# | __init__()
# ----------------------------------------------------------------------
def __init__(self, parent=None):
super().__init__(parent)
# ------------------------------------------------------------[override]
# | setData()
# ----------------------------------------------------------------------
def setData(self, index, value, role=QtCore.Qt.EditRole):
# Call source model's setData()
super().setData(index, value, role)
# Re-sort
self.sort(0, QtCore.Qt.AscendingOrder)
self.invalidate()
# ------------------------------------------------------------------------------
# | TreeView |
# | |
# | |
# | |
# | QTreeView object |
# ------------------------------------------------------------------------------
class TreeView(QtWidgets.QTreeView):
def __init__(self, parent=None):
super().__init__(parent)
def closeEditor(self, editor, hint):
proxy_model = self.model()
proxy_idx = self.currentIndex()
if not proxy_idx.data():
source_model = proxy_model.sourceModel()
source_idx = proxy_model.mapToSource(proxy_idx)
source_model.removeRows(source_idx.row(), 1, source_model.parent(source_idx))
super().closeEditor(editor, hint)
# ------------------------------------------------------------------------------
# | Dlg |
# | |
# | |
# | |
# | Dialog widget (defined in dlg.ui) |
# ------------------------------------------------------------------------------
dlg_base, dlg_form = uic.loadUiType('dlg.ui')
class Dlg(dlg_base, dlg_form):
def __init__(self, parent=None):
super(dlg_base, self).__init__(parent)
self.setupUi(self)
self.setWindowFlags(QtCore.Qt.Dialog|QtCore.Qt.FramelessWindowHint)
self.treeView.setHeaderHidden(True)
self.treeView.setAnimated(True)
# Set model and proxy
category_data = [
(4, 'Auto', None), (10, 'Auto', 'Gas'), (9, 'Auto', 'License'),
(11, 'Auto', 'Service'),
(23, 'Beer', None),
(12, 'Clothing', None), (13, 'Clothing', 'Cleaning'),
(18, 'Dining', None), (19, 'Dining', 'Breakfast'), (21, 'Dining', 'Lunch'),
(14, 'Healthcare', None), (26, 'Healthcare', 'Dental'),
(3, 'Household', None),
(5, 'Pet Expenses', None), (7, 'Pet Expenses', 'Biskies'),
(17, 'Pet Expenses', 'Chow'), (8, 'Pet Expenses', 'Grooming'),
(6, 'Pet Expenses', 'Medical'), (15, 'Pet Expenses', 'Miscellaneous'),
(25, 'Reimbursible', None)
]
self.model = CategoryTreeModel(category_data)
self.proxy = Proxy()
self.proxy.setSourceModel(self.model)
self.treeView.setModel(self.proxy)
# Set Delegate
self.delegate = CategoryDelegate(self.treeView)
self.treeView.setItemDelegate(self.delegate)
# Connect buttons
self.addMainButton.clicked.connect(self.add_main)
self.addSubButton.clicked.connect(self.add_sub)
self.deleteButton.clicked.connect(self.delete)
self.doneButton.clicked.connect(quit)
def keyPressEvent(self, event):
key = event.key()
mod = int(event.modifiers())
if key == QtCore.Qt.Key_Q and mod == QtCore.Qt.CTRL:
exit()
def add_main(self):
print('Add Main')
view = self.treeView
model = view.model()
model.insertRows(model.rowCount(), 1)
index = model.index(model.rowCount() - 1, 0)
view.setCurrentIndex(index)
view.scrollTo(index, QtWidgets.QAbstractItemView.EnsureVisible)
view.edit(index)
def add_sub(self):
print('Add Sub')
view = self.treeView
model = view.model()
current = view.currentIndex()
parent = model.parent(current)
add_to = parent if parent.isValid() else current
model.insertRows(model.rowCount(add_to), 1, add_to)
index = model.index(model.rowCount(add_to) - 1, 0, add_to)
view.setCurrentIndex(index)
view.scrollTo(index, QtWidgets.QAbstractItemView.EnsureVisible)
view.edit(index)
def delete(self):
print('Delete')
view = self.treeView
model = view.model()
current = view.currentIndex()
model.removeRows(current.row(), 1, model.parent(current))
# ------------------------------------------------------------------------------
# | CategoryDelegate |
# | |
# | |
# | |
# | Delegate for editing |
# ------------------------------------------------------------------------------
class CategoryDelegate(QtWidgets.QStyledItemDelegate):
def __init__(self, parent=None):
super().__init__(parent)
def createEditor(self, parent, option, index):
self.editor = QtWidgets.QLineEdit(parent)
return self.editor
def setEditorData(self, editor, index):
pass
def updateEditorGeometry(self, editor, option, index):
editor.setGeometry(option.rect)
def setModelData(self, editor, model, index):
model.setData(index, editor.text())
# ------------------------------------------------------------------------------
# | main() |
# | |
# | |
# | |
# | |
# ------------------------------------------------------------------------------
if __name__ == '__main__':
app = QtWidgets.QApplication(sys.argv)
app.setStyle('Fusion')
dlg = Dlg()
dlg.show()
sys.exit(app.exec_())
dlg.ui
(text/xml, 4.2 KB)
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>CategoryDialog</class>
<widget class="QDialog" name="CategoryDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>381</width>
<height>423</height>
</rect>
</property>
<property name="font">
<font>
<family>Corbel</family>
<pointsize>10</pointsize>
</font>
</property>
<property name="windowTitle">
<string>Dialog</string>
</property>
<property name="modal">
<bool>true</bool>
</property>
<widget class="QWidget" name="layoutWidget">
<property name="geometry">
<rect>
<x>10</x>
<y>10</y>
<width>361</width>
<height>401</height>
</rect>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="TreeView" name="treeView">
<property name="font">
<font>
<family>Corbel</family>
<pointsize>10</pointsize>
</font>
</property>
<property name="cursor" stdset="0">
<cursorShape>PointingHandCursor</cursorShape>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QToolButton" name="addMainButton">
<property name="font">
<font>
<pointsize>9</pointsize>
</font>
</property>
<property name="text">
<string>Main</string>
</property>
<property name="iconSize">
<size>
<width>40</width>
<height>40</height>
</size>
</property>
<property name="toolButtonStyle">
<enum>Qt::ToolButtonTextUnderIcon</enum>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="addSubButton">
<property name="font">
<font>
<pointsize>9</pointsize>
</font>
</property>
<property name="text">
<string>Sub</string>
</property>
<property name="iconSize">
<size>
<width>40</width>
<height>40</height>
</size>
</property>
<property name="toolButtonStyle">
<enum>Qt::ToolButtonTextUnderIcon</enum>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="deleteButton">
<property name="font">
<font>
<pointsize>9</pointsize>
</font>
</property>
<property name="text">
<string>Delete</string>
</property>
<property name="iconSize">
<size>
<width>40</width>
<height>40</height>
</size>
</property>
<property name="toolButtonStyle">
<enum>Qt::ToolButtonTextUnderIcon</enum>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QToolButton" name="doneButton">
<property name="font">
<font>
<pointsize>9</pointsize>
</font>
</property>
<property name="text">
<string>Done</string>
</property>
<property name="iconSize">
<size>
<width>40</width>
<height>40</height>
</size>
</property>
<property name="toolButtonStyle">
<enum>Qt::ToolButtonTextUnderIcon</enum>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</widget>
<customwidgets>
<customwidget>
<class>TreeView</class>
<extends>QTreeView</extends>
<header>cat.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>