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>
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.