Issue
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 oflessThan()
, 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())
Answered By - musicamante
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.