Search code examples
python-3.xqtpyside6pyqt6

Displaying a virtual folder/file representing the parent directory in a QTreeView displaying the directory using a QFileSystemModel


I am trying to implement a file viewer that, when double clicking a folder, sets this folder as the root. To allow the user to go back, I want to display a virtual file/directory that when double clicked sets the root back to the parent of the current open folder.

Concretely, I want to display / have the following:

Folder A (~/path/to/this/open/folder)
   -> Parent
   -> Folder B
   -> File A
   -> ...

After double clicking Folder B, I would then have

Folder B (~/path/to/this/open/folder)
   -> Parent
   -> ...

With Parent now linking to folder A.

I have considered multiple things: subclassing QFileSystemModel or QTreeView, subclassing from QAbstractItemModel, using mkdir on an instance of QFileSystemModel. Since the parent folder is purely a UI thing, I guess it would make the most sense to subclass QTreeView but even then I am a bit stuck on how to do it. Specifically, adding a virtual parent file/directory (and, but of less importance, displaying the path information of the open folder). Setting the root to the index of the double clicked folder I was able to do.

For completness sake, I have distilled a minimal working example of my current code:

import sys
from functools import partial
from typing import Union

from PySide6.QtCore import QModelIndex, QDir, QSortFilterProxyModel, QPersistentModelIndex
from PySide6.QtWidgets import QApplication, QTreeView, QFileSystemModel


class CustomProxyModel(QSortFilterProxyModel):

    NAME_COLUMN = 0

    def filterAcceptsColumn(self, source_column: int, source_parent: Union[QModelIndex, QPersistentModelIndex]) -> bool:
        return source_column == self.NAME_COLUMN

    def isDir(self, proxy_index: QModelIndex):
        source_index = self.mapToSource(proxy_index)
        source_model = self.sourceModel()

        if source_model and source_index.isValid():
            return source_model.isDir(source_index)
        else:
            return False


def on_folder_double_clicked(proxy, tree, index):
        if proxy.isDir(index):
            tree.setRootIndex(index)


if __name__ == '__main__':
    app = QApplication(sys.argv)

    tree = QTreeView()
    tree.header().hide()

    path = QDir.rootPath() + 'Users/'

    filesystem_model = QFileSystemModel()
    filesystem_model.setReadOnly(True)
    filesystem_model.setRootPath(path)

    proxy = CustomProxyModel()
    proxy.setSourceModel(filesystem_model)

    source_index = filesystem_model.index(path, 0)

    tree.setModel(proxy)
    on_folder_double_clicked(proxy, tree, proxy.mapFromSource(source_index))
    tree.doubleClicked.connect(partial(on_folder_double_clicked, proxy, tree))

    tree.show()
    sys.exit(app.exec())


Solution

  • Such a model/view pair cannot just consider the parent for the proxy filter, as the object hierarchy must be properly understood.

    Most importantly, in order to show the "parent" folder:

    • the view should use the parent of that parent as its root item;
    • the proxy should only filter out the siblings of any item of the "parent" and do the same for all parent items above it;
    • the proxy should always show items that contain the "parent" and filter out anything else, except when the root path is the file system root (/ on *nix and "Computer" on Windows);

    Unfortunately, your ideas for implementing this are not acceptable:

    • subclassing QFileSystemModel or QTreeView is not sufficient nor valid:
      • models should never be aware about the state of the view(s) connected to them; while you can try to create a carefully crafted system that allows the model to directly interact with its view, such approach is risky at best, and making it reliable may be very difficult;
      • views don't have control on their models (just like models don't have control on the views displaying them), meaning that the view cannot (easily) directly decide what to show; even if you try to use functions like setRowHidden(), that may become quite difficult to implement with a QFileSystemModel, because files and folders can be created/deleted/moved/renamed outside of the application, resulting in unexpected behavior;
    • subclassing from QAbstractItemModel may be a possibility, but you'd still need to have an underlying file system model that promptly changes the Qt item model upon changes; implementing all that may be extremely complicated and not worth the effort;
    • using mkdir on an instance of QFileSystemModel is completely inappropriate, and for a lot of reasons:
      • what if the path already contains a directory with the same name?
      • unless you set a proper order for the items, the "dummy" directory may be placed at a wrong index due to the sorting order (for example, _ normally comes before than .);

    Note that QFileSystemModel already provides a similar feature, common in some file browsers that inherit command line behavior: the .. ("dot-dot") path corresponds to the parent folder.

    By default, QFileSystemModel does not show such virtual items, since it's initial filter uses QDir.NoDotDot, so it's necessary to call [setFilter()`](https://doc.qt.io/qt-6/qfilesystemmodel.html#setFilter) with the current filter options while removing that flag:

    model.setFilter(model.filter() & ~QDir.Filter.NoDotDot)
    

    With the above, if you are not interested in providing the possibility of expanding/collapsing of directory branches, then you can just use a basic QListView.

    Regardless the view you will eventually use, the above won't be without issues, though; for example:

    • while the .. item is normally on top of a directory listing, in some cases it may be shown below some items (also depending on the OS);
    • sorting the model might show the .. at the wrong place;
    • .. is also shown for the root path;

    A possible implementation will use a QSortFilterProxyModel subclass with an underlying QFileSystemModel:

    class FileSystemModelDotDot(QSortFilterProxyModel):
        rootPathChanged = pyqtSignal(str)
        rootIndexChanged = pyqtSignal(QModelIndex)
        def __init__(self, path=None):
            super().__init__()
            self.fsModel = QFileSystemModel()
            self.fsModel.setRootPath(path or QDir.homePath())
            self.fsModel.setFilter(self.fsModel.filter() & ~QDir.Filter.NoDotDot)
            self.setSourceModel(self.fsModel)
    
            self.fsModel.rootPathChanged.connect(self.emitRootChanged)
    
            self.sort(0, Qt.SortOrder.AscendingOrder)
    
        def emitRootChanged(self, path):
            self.rootPathChanged.emit(path)
            self.rootIndexChanged.emit(self.rootIndex())
    
        def columnCount(self, parent=None):
            # if only the first column should be visible, there is no point in
            # using filterAcceptsColumn
            return 1
    
        def data(self, index, role=Qt.ItemDataRole.DisplayRole):
            if (
                role == Qt.ItemDataRole.DecorationRole 
                and index.row() == 0 
                and index.column() == 0
                and super().data(index, Qt.ItemDataRole.DisplayRole) == '..'
            ):
                return QApplication.style().standardIcon(
                    QStyle.StandardPixmap.SP_FileDialogToParent)
    
            return super().data(index, role)
    
        def indexForPath(self, path):
            return self.mapFromSource(self.fsModel.index(path))
    
        def filterAcceptsRow(self, row, parent):
            # always hide the ".." for the system root path and for sub
            # directories of the current path
            if self.fsModel.index(row, 0, parent).data() == '..' and (
                not parent.parent().isValid()
                or parent != self.fsModel.index(self.fsModel.rootPath())
            ):
                return False
            return super().filterAcceptsRow(row, parent)
    
        def hasChildren(self, parent):
            # prevent showing the arrow and expanding the ".." item
            return parent.data() != '..' and super().hasChildren(parent)
    
        def isDir(self, index):
            return self.fsModel.isDir(self.mapToSource(index))
    
        def filePath(self, index):
            return self.fsModel.filePath(self.mapToSource(index))
    
        def lessThan(self, left, right):
            # ".." is always the first in the list, no matter the order
            if left.column() == 0:
                if left.data() == '..':
                    return True
                elif right.data() == '..':
                    return False
            elif left.siblingAtColumn(0).data() == '..':
                return True
            elif right.siblingAtColumn(0).data() == '..':
                return False
            return left.row() < right.row()
    
        def rootIndex(self):
            return self.indexForPath(self.fsModel.rootPath())
    
        def rootPath(self):
            return self.fsModel.rootPath()
    
        def setRootPath(self, path):
            if isinstance(path, QDir):
                path = path.path()
            if QFileInfo.exists(path) and QFileInfo(path).isReadable():
                self.fsModel.setRootPath(QDir.cleanPath(path))
            return self.rootIndex()
    
        def sort(self, column, order):
            # sort the source, not the proxy
            self.fsModel.sort(column, order)
            # required to force lessThan
            super().sort(column, Qt.SortOrder.AscendingOrder)
    

    Here is how it works:

    • the file system model is "private" to the proxy, as there's no need to use an external object; this also makes it simpler to access it from the proxy functions and overrides;
    • sort() actually sorts the source model, while keeping the proxy sorting intact, but still allowing usage of lessThan(), which can be used to ensure that the .. path is always the first item on top;
    • the custom setRootPath() function sets the root path of the file system model, if it is accessible;
    • if the fs model actually changes its root path (rootPathChanged is emitted) then two custom signals are emitted accordingly, specifically the new "root" index (rootIndexChanged);
    • the view connects to the above signal with its setRootIndex(), so that it automatically changes the root when the model does;

    An example of usage:

    class DirTreeViewBase(QTreeView):
        pathChanged = pyqtSignal(str)
        def __init__(self, path=None):
            super().__init__()
            self.setExpandsOnDoubleClick(False)
            self.header().hide()
    
            self.proxy = FileSystemModelDotDot(path)
    
            self.setModel(self.proxy)
            self.setRootIndex(self.proxy.rootIndex())
            self.proxy.rootPathChanged.connect(self.pathChanged)
            self.proxy.rootIndexChanged.connect(self.setRootIndex)
    
            self.doubleClicked.connect(self.setPathIndex)
    
        def currentPath(self):
            return self.proxy.rootPath()
    
        def setPathIndex(self, index):
            if self.proxy.isDir(index):
                path = QDir(self.proxy.filePath(index)).path()
                self.proxy.setRootPath(path)
    
    if __name__ == '__main__':
        import sys
        app = QApplication(sys.argv)
    
        def updatePath(path):
            win.statusBar().showMessage('Current path: ' + path)
    
        win = QMainWindow()
    
        view = DirTreeViewBase('/tmp')
        win.setCentralWidget(view)
        view.pathChanged.connect(updatePath)
        updatePath(view.currentPath())
    
        win.resize(400, 600)
        r = win.geometry()
        r.moveCenter(app.primaryScreen().geometry().center())
        win.setGeometry(r)
        win.show()
    
        sys.exit(app.exec())