Search code examples
pythonpython-3.xpyqtpyqt5qfilesystemmodel

QFileSystemModel with Checkboxes


So, I have this simple PyQt5 code which is essentially a file explorer. I need to be able to select arbitrary files or groups of files (directories and all of the children). I would like to:

  1. Add a Checkbox next to each item
  2. If an item is checked/uncheck and has sub-items, the state of the sub-items should be set to the state of the item. So if you check a directory, everything underneath it should also get checked.
  3. When an items check state is changed, directly or indirectly, I need to invoke a callback with the full path (relative to the root) of the item.

I am essentially building a list of selected files to process.

import sys
from PyQt5.QtWidgets import QApplication, QFileSystemModel, QTreeView, QWidget, QVBoxLayout
from PyQt5.QtGui import QIcon

class App(QWidget):

    def __init__(self):
        super().__init__()
        self.title = 'PyQt5 file system view - pythonspot.com'
        self.left = 10
        self.top = 10
        self.width = 640
        self.height = 480
        self.initUI()
    
    def initUI(self):
        self.setWindowTitle(self.title)
        self.setGeometry(self.left, self.top, self.width, self.height)
        
        self.model = QFileSystemModel()
        self.model.setRootPath('')
        self.tree = QTreeView()
        self.tree.setModel(self.model)
        
        self.tree.setAnimated(False)
        self.tree.setIndentation(20)
        self.tree.setSortingEnabled(True)
        
        self.tree.setWindowTitle("Dir View")
        self.tree.resize(640, 480)
        
        windowLayout = QVBoxLayout()
        windowLayout.addWidget(self.tree)
        self.setLayout(windowLayout)
        
        self.show()

if __name__ == '__main__':
    app = QApplication(sys.argv)
    ex = App()
    sys.exit(app.exec_())

Solution

  • The QFileSystemModel doesn't load the contents of a directory until explicitly requested (in case of a tree view, it onyl happens when the directory is expanded the first time).

    This requires to carefully verify and set the check state of each path recursively not only whenever a new file or directory is added (or renamed/removed), but also when the directory contents are actually loaded.

    In order to correctly implement this, the check states should also be stored using file paths, because when the contents of a directory change some indexes might be invalidated.

    The following implementation should take care of all written above, and emit a signal only when an item state is actively changed and the parent state is changed, but not for the children items of a checked directory.
    While this choice might seem partially incoherent, it's a performance requirement, as you cannot get the individual signals for each subdirectory (nor you might want to): if you check the top level directory, you might receive thousands of unwanted notifications; on the other hand, it might be important to receive a notification if the parent directory state has changed, whenever all items become checked or unchecked.

    from PyQt5 import QtCore, QtWidgets
    
    class CheckableFileSystemModel(QtWidgets.QFileSystemModel):
        checkStateChanged = QtCore.pyqtSignal(str, bool)
        def __init__(self):
            super().__init__()
            self.checkStates = {}
            self.rowsInserted.connect(self.checkAdded)
            self.rowsRemoved.connect(self.checkParent)
            self.rowsAboutToBeRemoved.connect(self.checkRemoved)
    
        def checkState(self, index):
            return self.checkStates.get(self.filePath(index), QtCore.Qt.Unchecked)
    
        def setCheckState(self, index, state, emitStateChange=True):
            path = self.filePath(index)
            if self.checkStates.get(path) == state:
                return
            self.checkStates[path] = state
            if emitStateChange:
                self.checkStateChanged.emit(path, bool(state))
    
        def checkAdded(self, parent, first, last):
            # if a file/directory is added, ensure it follows the parent state as long
            # as the parent is already tracked; note that this happens also when 
            # expanding a directory that has not been previously loaded
            if not parent.isValid():
                return
            if self.filePath(parent) in self.checkStates:
                state = self.checkState(parent)
                for row in range(first, last + 1):
                    index = self.index(row, 0, parent)
                    path = self.filePath(index)
                    if path not in self.checkStates:
                        self.checkStates[path] = state
            self.checkParent(parent)
    
        def checkRemoved(self, parent, first, last):
            # remove items from the internal dictionary when a file is deleted; 
            # note that this *has* to happen *before* the model actually updates, 
            # that's the reason this function is connected to rowsAboutToBeRemoved
            for row in range(first, last + 1):
                path = self.filePath(self.index(row, 0, parent))
                if path in self.checkStates:
                    self.checkStates.pop(path)
    
        def checkParent(self, parent):
            # verify the state of the parent according to the children states
            if not parent.isValid():
                return
            childStates = [self.checkState(self.index(r, 0, parent)) for r in range(self.rowCount(parent))]
            newState = QtCore.Qt.Checked if all(childStates) else QtCore.Qt.Unchecked
            oldState = self.checkState(parent)
            if newState != oldState:
                self.setCheckState(parent, newState)
                self.dataChanged.emit(parent, parent)
            self.checkParent(parent.parent())
    
        def flags(self, index):
            return super().flags(index) | QtCore.Qt.ItemIsUserCheckable
    
        def data(self, index, role=QtCore.Qt.DisplayRole):
            if role == QtCore.Qt.CheckStateRole and index.column() == 0:
                return self.checkState(index)
            return super().data(index, role)
    
        def setData(self, index, value, role, checkParent=True, emitStateChange=True):
            if role == QtCore.Qt.CheckStateRole and index.column() == 0:
                self.setCheckState(index, value, emitStateChange)
                for row in range(self.rowCount(index)):
                    # set the data for the children, but do not emit the state change, 
                    # and don't check the parent state (to avoid recursion)
                    self.setData(index.child(row, 0), value, QtCore.Qt.CheckStateRole, 
                        checkParent=False, emitStateChange=False)
                self.dataChanged.emit(index, index)
                if checkParent:
                    self.checkParent(index.parent())
                return True
    
            return super().setData(index, value, role)
    
    
    class Test(QtWidgets.QWidget):
        def __init__(self):
            super().__init__()
            layout = QtWidgets.QVBoxLayout(self)
    
            self.tree = QtWidgets.QTreeView()
            layout.addWidget(self.tree, stretch=2)
    
            model = CheckableFileSystemModel()
            model.setRootPath('')
            self.tree.setModel(model)
            self.tree.setSortingEnabled(True)
            self.tree.header().setSectionResizeMode(QtWidgets.QHeaderView.Stretch)
    
            self.logger = QtWidgets.QPlainTextEdit()
            layout.addWidget(self.logger, stretch=1)
            self.logger.setReadOnly(True)
    
            model.checkStateChanged.connect(self.updateLog)
            self.resize(640, 480)
            QtCore.QTimer.singleShot(0, lambda: self.tree.expand(model.index(0, 0)))
    
        def updateLog(self, path, checked):
            if checked:
                text = 'Path "{}" has been checked'
            else:
                text = 'Path "{}" has been unchecked'
            self.logger.appendPlainText(text.format(path))
            self.logger.verticalScrollBar().setValue(
                self.logger.verticalScrollBar().maximum())
    
    
    if __name__ == '__main__':
        import sys
        app = QtWidgets.QApplication(sys.argv)
        test = Test()
        test.show()
        sys.exit(app.exec_())