mirror of
https://github.com/borgbase/vorta
synced 2025-03-11 06:33:46 +00:00
Implement fresh TreeModel for TreeViews.
* src/vorta/views/partials/treemodel.py * tests/test_treemodel.py : Write tests.
This commit is contained in:
parent
33639aeaed
commit
3cd940455c
3 changed files with 1221 additions and 2 deletions
|
@ -26,8 +26,9 @@ from vorta.borg.umount import BorgUmountJob
|
|||
from vorta.store.models import ArchiveModel, BackupProfileMixin
|
||||
from vorta.utils import (choose_file_dialog, format_archive_name, get_asset,
|
||||
get_mount_points, pretty_bytes)
|
||||
from vorta.views.diff_result import DiffResult
|
||||
from vorta.views.extract_dialog import ExtractDialog
|
||||
from vorta.views import diff_result, extract_dialog
|
||||
from vorta.views.diff_result import DiffResultDialog, DiffTree
|
||||
from vorta.views.extract_dialog import ExtractDialog, ExtractTree
|
||||
from vorta.views.source_tab import SizeItem
|
||||
from vorta.views.utils import get_colored_icon
|
||||
|
||||
|
|
892
src/vorta/views/partials/treemodel.py
Normal file
892
src/vorta/views/partials/treemodel.py
Normal file
|
@ -0,0 +1,892 @@
|
|||
"""
|
||||
Implementation of a tree model for use with `QTreeView` based on (file) paths.
|
||||
|
||||
"""
|
||||
|
||||
import bisect
|
||||
import enum
|
||||
import os.path as osp
|
||||
from functools import reduce
|
||||
from pathlib import PurePath
|
||||
from typing import (Generic, List, Optional, Sequence, Tuple, TypeVar, Union,
|
||||
overload)
|
||||
|
||||
from PyQt5.QtCore import QAbstractItemModel, QModelIndex, QObject, Qt
|
||||
|
||||
#: A representation of a path
|
||||
Path = Tuple[str, ...]
|
||||
PathLike = Union[Path, Sequence[str]]
|
||||
|
||||
|
||||
def relative_path(p1: PathLike, p2: PathLike) -> Path:
|
||||
"""Get p2 relative to p1."""
|
||||
if len(p2) <= len(p1):
|
||||
return ()
|
||||
|
||||
return tuple(p2[len(p1):])
|
||||
|
||||
|
||||
def path_to_str(path: PathLike) -> str:
|
||||
"""Return a string representation of a path."""
|
||||
if not path:
|
||||
return ''
|
||||
|
||||
return osp.join(*path)
|
||||
|
||||
|
||||
#: Type of FileSystemItem's data
|
||||
T = TypeVar('T')
|
||||
FileSystemItemLike = Union[Tuple[Union[PurePath, Path], Optional[T]],
|
||||
'FileSystemItem']
|
||||
|
||||
#: Default return value
|
||||
A = TypeVar('A')
|
||||
|
||||
|
||||
class FileSystemItem(Generic[T]):
|
||||
"""
|
||||
An item in the virtual file system.
|
||||
|
||||
..warning::
|
||||
|
||||
Do not edit `children` manually. Always use `add` or `remove` or
|
||||
`sort`.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
path : Path
|
||||
The path of this item.
|
||||
data : Any
|
||||
The data belonging to this item.
|
||||
children : List[FileSystemItem]
|
||||
The children of this item.
|
||||
_subpath : str
|
||||
The subpath of this item relative to its parent.
|
||||
_parent : FileSystemItem or None
|
||||
The parent of the item.
|
||||
"""
|
||||
__slots__ = ['path', 'children', 'data', '_parent', 'subpath']
|
||||
|
||||
def __init__(self, path: PathLike, data: T):
|
||||
"""Init."""
|
||||
self.path: Path = tuple(path)
|
||||
self.data = data
|
||||
self.subpath: str = None
|
||||
self.children: List[FileSystemItem[T]] = []
|
||||
self._parent: Optional[FileSystemItem[T]] = None
|
||||
|
||||
# @property
|
||||
# def subpath(self) -> str:
|
||||
# """
|
||||
# Get the name of the item which is the subpath relative to its parent.
|
||||
# """
|
||||
# return self.path[-1]
|
||||
|
||||
# @property
|
||||
# def children(self):
|
||||
# """Get an iterable view of the item's children."""
|
||||
# return self.child_map.values()
|
||||
|
||||
def add(self,
|
||||
child: 'FileSystemItem[T]',
|
||||
_subpath: str = None,
|
||||
_check: bool = True):
|
||||
"""
|
||||
Add a child.
|
||||
|
||||
The parameters starting with an underscore exist for performance
|
||||
reasons only. They should only be used if the operations that these
|
||||
parameters toggle were performed already.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
child : FileSystemItem
|
||||
The child to add.
|
||||
_subpath : str, optional
|
||||
Precalculated subpath, default is None.
|
||||
_check : bool, optional
|
||||
Whether to check for children with the same subpath (using `get`).
|
||||
"""
|
||||
if _subpath is not None:
|
||||
child.subpath = _subpath
|
||||
else:
|
||||
child.subpath = path_to_str(relative_path(self.path, child.path))
|
||||
|
||||
i = bisect.bisect(self.children, child)
|
||||
|
||||
# check for a child with the same subpath
|
||||
if _check and len(self.children) > i - 1 >= 0 \
|
||||
and self.children[i - 1].subpath == child.subpath:
|
||||
raise RuntimeError(
|
||||
"The subpath must be unique to a parent's children.")
|
||||
|
||||
# add
|
||||
child._parent = self
|
||||
self.children.insert(i, child)
|
||||
|
||||
def addChildren(self, children: List['FileSystemItem[T]']):
|
||||
"""
|
||||
Add a list of children.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
children : List[FileSystemItem]
|
||||
The children to add.
|
||||
"""
|
||||
for child in children:
|
||||
self.add(child)
|
||||
|
||||
@overload
|
||||
def remove(self, subpath: str) -> None:
|
||||
pass
|
||||
|
||||
@overload
|
||||
def remove(self, index: int) -> None:
|
||||
pass
|
||||
|
||||
@overload
|
||||
def remove(self, child: 'FileSystemItem[T]') -> None:
|
||||
pass
|
||||
|
||||
def remove(self, child_subpath_index):
|
||||
"""
|
||||
Remove the given children.
|
||||
|
||||
The index or child to remove must be in the list
|
||||
else an error will be raised.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
child_or_index : FileSystemItem or int
|
||||
The instance to remove or its index in `children`.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
The given item is not a child of this one.
|
||||
IndexError
|
||||
The given index is not a valid one.
|
||||
"""
|
||||
if isinstance(child_subpath_index, FileSystemItem):
|
||||
child = child_subpath_index
|
||||
i = bisect.bisect_left(self.children, child)
|
||||
if i < len(self.children) and self.children[i] == child:
|
||||
del self.children[i]
|
||||
|
||||
elif isinstance(child_subpath_index, str):
|
||||
subpath = child_subpath_index
|
||||
i = bisect.bisect_left(self.children, subpath)
|
||||
if i < len(self.children) and self.children[i].subpath == subpath:
|
||||
del self.children[i]
|
||||
|
||||
elif isinstance(child_subpath_index, int):
|
||||
i = child_subpath_index
|
||||
del self.children[i]
|
||||
|
||||
else:
|
||||
raise TypeError(
|
||||
"First argument passed to `{}.remove` has invalid type {}".
|
||||
format(
|
||||
type(self).__name__,
|
||||
type(child_subpath_index).__name__))
|
||||
|
||||
def __getitem__(self, index: int):
|
||||
"""
|
||||
Get a an item.
|
||||
|
||||
This allows accessing the attributes in the same manner for instances
|
||||
of this type and instances of `FileSystemItemLike`.
|
||||
"""
|
||||
if index == 0:
|
||||
return self.path
|
||||
elif index == 1:
|
||||
return self.data
|
||||
else:
|
||||
raise IndexError("Index {} out of range(0, 2)".format(index))
|
||||
|
||||
def get(
|
||||
self,
|
||||
subpath: str,
|
||||
default: Optional[A] = None
|
||||
) -> Union[Tuple[int, 'FileSystemItem[T]'], Optional[A]]:
|
||||
"""
|
||||
Find direct child with given subpath.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
subpath : str
|
||||
The items subpath relative to this.
|
||||
default : Any, optional
|
||||
The item to return if the child wasn't found, default None.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Tuple[int, FileSystemItem] or None
|
||||
The index and item if found else `default`.
|
||||
"""
|
||||
i = bisect.bisect_left(self.children, subpath)
|
||||
if i < len(self.children):
|
||||
child = self.children[i]
|
||||
if child.subpath == subpath:
|
||||
return i, child
|
||||
return default
|
||||
|
||||
def get_path(self, path: PathLike) -> Optional['FileSystemItem[T]']:
|
||||
"""
|
||||
Get the item with the given subpath relative to this item.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path : Path
|
||||
The subpath.
|
||||
"""
|
||||
|
||||
def walk(fsi, pp):
|
||||
if fsi is None:
|
||||
return None
|
||||
res = fsi.get(pp)
|
||||
if not res:
|
||||
return None
|
||||
i, item = res
|
||||
return item
|
||||
|
||||
fsi = reduce(walk, path, self)
|
||||
return fsi
|
||||
|
||||
def __repr__(self):
|
||||
"""Get a string representation."""
|
||||
return "FileSystemItem<'{}', '{}', {}, {}>".format(
|
||||
self.path,
|
||||
self.subpath,
|
||||
self.data,
|
||||
[c.subpath for c in self.children],
|
||||
)
|
||||
|
||||
def __lt__(self, other):
|
||||
"""Lower than for bisect sorting."""
|
||||
if isinstance(other, FileSystemItem):
|
||||
return self.subpath < other.subpath
|
||||
if isinstance(other, (list, tuple)):
|
||||
for s, o in zip(self.path, other):
|
||||
if s != o:
|
||||
return s < o
|
||||
else:
|
||||
return len(self.path) < len(other)
|
||||
else:
|
||||
return self.subpath < other
|
||||
|
||||
def __gt__(self, other):
|
||||
"""Greater than for bisect sorting."""
|
||||
if isinstance(other, FileSystemItem):
|
||||
return self.subpath > other.subpath
|
||||
if isinstance(other, (list, tuple)):
|
||||
for s, o in zip(self.path, other):
|
||||
if s != o:
|
||||
return s > o
|
||||
else:
|
||||
return len(self.path) > len(other)
|
||||
else:
|
||||
return self.subpath > other
|
||||
|
||||
|
||||
class FileTreeModel(QAbstractItemModel, Generic[T]):
|
||||
"""
|
||||
FileTreeModel managing a virtual file system with variable file data.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
mode: DisplayMode
|
||||
The tree display mode of the model.
|
||||
|
||||
Methods
|
||||
-------
|
||||
_make_filesystemitem(path, data, children)
|
||||
Construct a `FileSystemItem`.
|
||||
_merge_data(item, data)
|
||||
Add the given data to the item.
|
||||
_flat_filter
|
||||
Return whether an item is part of the flat model representation.
|
||||
flags
|
||||
columnCount
|
||||
headerData
|
||||
|
||||
"""
|
||||
|
||||
class DisplayMode(enum.Enum):
|
||||
"""
|
||||
The tree display modes available for the model.
|
||||
|
||||
"""
|
||||
#: normal file tree
|
||||
TREE = enum.auto()
|
||||
|
||||
#: combine items in the tree having a single child with that child
|
||||
SIMPLIFIED_TREE = enum.auto()
|
||||
|
||||
#: simple list of items
|
||||
FLAT = enum.auto()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
"""Init."""
|
||||
super().__init__(parent)
|
||||
self.root: FileSystemItem[T] = FileSystemItem([], None)
|
||||
|
||||
#: mode
|
||||
self.mode: 'FileTreeModel.DisplayMode' = self.DisplayMode.TREE
|
||||
|
||||
#: flat representation of the tree
|
||||
self._flattened: List[FileSystemItem] = []
|
||||
|
||||
def addItems(self, items: List[FileSystemItemLike[T]]):
|
||||
"""
|
||||
Add file system items to the model.
|
||||
|
||||
This method can be used for populating the model.
|
||||
Calls `addItem` for each item.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
items : List[FileSystemItemLike]
|
||||
The items.
|
||||
"""
|
||||
for item in items:
|
||||
self.addItem(item)
|
||||
|
||||
def addItem(self, item: FileSystemItemLike[T]):
|
||||
"""
|
||||
Add a file system item to the model.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
item : FileSystemItemLike
|
||||
The item.
|
||||
"""
|
||||
self.beginResetModel()
|
||||
|
||||
path = item[0]
|
||||
data = item[1]
|
||||
|
||||
if isinstance(path, PurePath):
|
||||
path = path.parts
|
||||
|
||||
def child(tup, subpath):
|
||||
fsi, i = tup
|
||||
i += 1
|
||||
return self._addChild(fsi, path[:i], subpath, None), i
|
||||
|
||||
fsi, dummy = reduce(child, path[:-1], (self.root, 0))
|
||||
|
||||
self._addChild(fsi, path, path[-1], data)
|
||||
|
||||
self.endResetModel()
|
||||
|
||||
def _addChild(self, item: FileSystemItem[T], path: PathLike,
|
||||
path_part: str, data: Optional[T]) -> FileSystemItem[T]:
|
||||
"""
|
||||
Add a child to an item.
|
||||
|
||||
This is called by `addItem` in a reduce statement. It should
|
||||
add a new child with the given attributes to the given item.
|
||||
This implementation provides a reasonable default, most subclasses
|
||||
wont need to override this method. The implementation should make use
|
||||
of `_make_filesystemitem`, `_merge_data`, `_add_children`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
item : FileSystemItem
|
||||
The item to add a new child to.
|
||||
path : PathLike
|
||||
The path of the new child.
|
||||
path_part : str
|
||||
The subpath of the new child relative to `item`.
|
||||
data : Any or None
|
||||
The data of the new child.
|
||||
children : Any or None
|
||||
The initial children of the item.
|
||||
|
||||
Returns
|
||||
-------
|
||||
FileSystemItem
|
||||
[description]
|
||||
"""
|
||||
res = item.get(path_part)
|
||||
if res:
|
||||
i, child = res
|
||||
if data is not None:
|
||||
self._merge_data(child, data)
|
||||
else:
|
||||
child = self._make_filesystemitem(path, data)
|
||||
|
||||
if self._flat_filter(child):
|
||||
i = bisect.bisect(self._flattened, child.path)
|
||||
self._flattened.insert(i, child)
|
||||
|
||||
item.add(child, _subpath=path_part, _check=False)
|
||||
|
||||
# update parent data
|
||||
self._process_child(child)
|
||||
|
||||
return child
|
||||
|
||||
def _make_filesystemitem(self, path: PathLike,
|
||||
data: Optional[T]) -> FileSystemItem[T]:
|
||||
"""
|
||||
Construct a `FileSystemItem`.
|
||||
|
||||
The attributes are the attributes of a `FileSystemItemLike`.
|
||||
This implementation already provides reasonable default that
|
||||
subclasses can be used.
|
||||
|
||||
..warning:: Do always call `_addChild` to add a child to an item.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path : PathLike
|
||||
The path of the item.
|
||||
data : Any or None
|
||||
The data.
|
||||
children : Any or None
|
||||
The initial children.
|
||||
|
||||
Returns
|
||||
-------
|
||||
FileSystemItem
|
||||
The FileSystemItem for the internal tree structure.
|
||||
"""
|
||||
return FileSystemItem(path, data)
|
||||
|
||||
def _process_child(self, child: FileSystemItem[T]):
|
||||
"""
|
||||
Process a new child.
|
||||
|
||||
This can make some changes to the child's data like
|
||||
setting a default value if the child's data is None.
|
||||
This can also update the data of the parent.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
child : FileSystemItem
|
||||
The child that was added.
|
||||
"""
|
||||
pass # Does nothing
|
||||
|
||||
def _merge_data(self, item: FileSystemItem[T], data: Optional[T]):
|
||||
"""
|
||||
Add the given data to the item.
|
||||
|
||||
This method is called by `_addChild` which in turn is called by
|
||||
`addItem`. It gets an item in the virtual file system that was
|
||||
added again with the given data. The data may be None.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
item : FileSystemItem
|
||||
The item to merge the data in.
|
||||
data : Any or None
|
||||
The data to add.
|
||||
"""
|
||||
if not item.data:
|
||||
item.data = data
|
||||
|
||||
def removeItem(self, path: Union[PurePath, PathLike]) -> None:
|
||||
"""
|
||||
Remove an item from the model.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path : PathLike or PurePath
|
||||
The path of the item to remove.
|
||||
"""
|
||||
if isinstance(path, PurePath):
|
||||
path = path.parts
|
||||
|
||||
if not path:
|
||||
return
|
||||
|
||||
self.beginResetModel()
|
||||
|
||||
parent = self.getItem(path[:-1])
|
||||
|
||||
if not parent:
|
||||
return
|
||||
|
||||
res = parent.get(path[-1])
|
||||
|
||||
if not res:
|
||||
return
|
||||
|
||||
i, item = res
|
||||
|
||||
# remove item and its children in flat representation
|
||||
items_to_remove: List[FileSystemItem] = [item]
|
||||
while items_to_remove:
|
||||
to_remove = items_to_remove.pop()
|
||||
|
||||
fi = bisect.bisect_left(self._flattened, to_remove.path)
|
||||
if fi < len(self._flattened) and self._flattened[fi] is to_remove:
|
||||
del self._flattened[fi]
|
||||
|
||||
items_to_remove.extend(to_remove.children)
|
||||
|
||||
# remove item from tree representation
|
||||
parent.remove(i)
|
||||
|
||||
self.endResetModel()
|
||||
|
||||
def setMode(self, value: 'DisplayMode'):
|
||||
"""
|
||||
Set the display mode of the tree model.
|
||||
|
||||
In TREE mode (default) the tree will be displayed as is.
|
||||
In SIMPLIFIED_TREE items will simplify the tree by combining
|
||||
items with their single child if they posess only one.
|
||||
In FLAT mode items will be displayed as a simple list. The items
|
||||
shown can be filtered by `_flat_filter`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
value : bool
|
||||
The new value for the attribute.
|
||||
|
||||
See also
|
||||
--------
|
||||
getMode: Get the current mode.
|
||||
_flat_filter
|
||||
"""
|
||||
if value == self.mode:
|
||||
return # nothing to do
|
||||
|
||||
self.beginResetModel()
|
||||
self.mode = value
|
||||
self.endResetModel()
|
||||
|
||||
def getMode(self) -> bool:
|
||||
"""
|
||||
Get the display mode set.
|
||||
|
||||
Returns
|
||||
-------
|
||||
DisplayMode
|
||||
The current value.
|
||||
|
||||
See also
|
||||
--------
|
||||
setMode : Set the mode.
|
||||
"""
|
||||
return self.mode
|
||||
|
||||
def _flat_filter(self, item: FileSystemItem[T]) -> bool:
|
||||
"""
|
||||
Return whether an item is part of the flat model representation.
|
||||
"""
|
||||
return True
|
||||
|
||||
def _simplify_filter(self, item: FileSystemItem[T]) -> bool:
|
||||
"""
|
||||
Return whether an item may be merged in simplified mode.
|
||||
"""
|
||||
return True
|
||||
|
||||
def getItem(
|
||||
self, path: Union[PurePath,
|
||||
PathLike]) -> Optional[FileSystemItem[T]]:
|
||||
"""
|
||||
Get the item with the given path.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path : PathLike or PurePath
|
||||
The path of the item.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Optional[FileSystemItem]
|
||||
[description]
|
||||
"""
|
||||
if isinstance(path, PurePath):
|
||||
path = path.parts
|
||||
|
||||
return self.root.get_path(path)
|
||||
|
||||
def data(self,
|
||||
index: QModelIndex,
|
||||
role: int = Qt.ItemDataRole.DisplayRole):
|
||||
"""
|
||||
Get the data for the given role and index.
|
||||
|
||||
The indexes internal pointer references the corresponding
|
||||
`FileSystemItem`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
index : QModelIndex
|
||||
The index of the item.
|
||||
role : int, optional
|
||||
The data role, by default Qt.ItemDataRole.DisplayRole
|
||||
|
||||
Returns
|
||||
-------
|
||||
Any
|
||||
The data, return None if no data is available for the role.
|
||||
"""
|
||||
return super().data(index, role)
|
||||
|
||||
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
|
||||
"""
|
||||
Returns the number of rows under the given parent.
|
||||
|
||||
When the parent is valid it means that rowCount is returning
|
||||
the number of children of parent.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
parent : QModelIndex, optional
|
||||
The index of the parent item, by default QModelIndex()
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The number of children.
|
||||
"""
|
||||
if parent.column() > 0:
|
||||
return 0 # Only the first column has children
|
||||
|
||||
# flat mode
|
||||
if self.mode == self.DisplayMode.FLAT:
|
||||
if not parent.isValid():
|
||||
return len(self._flattened)
|
||||
return 0
|
||||
|
||||
# tree mode
|
||||
if not parent.isValid():
|
||||
parent_item: FileSystemItem = self.root
|
||||
else:
|
||||
parent_item = parent.internalPointer()
|
||||
|
||||
return len(parent_item.children)
|
||||
|
||||
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
|
||||
"""
|
||||
Returns the number of columns for the children of the given parent.
|
||||
|
||||
This corresponds to the number of data (column) entries shown
|
||||
for each item in the tree view.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
parent : QModelIndex, optional
|
||||
The index of the parent, by default QModelIndex()
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The number of rows.
|
||||
"""
|
||||
raise NotImplementedError("Method `columnCount` of FileTreeModel" +
|
||||
" must be implemented by subclasses.")
|
||||
|
||||
def indexPath(self, path: Union[PurePath, PathLike]) -> QModelIndex:
|
||||
"""
|
||||
Construct a `QModelIndex` for the given path.
|
||||
|
||||
If `combine` is enabled, the closest indexed parent path is returned.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path : PurePath or PathLike
|
||||
The path to the item the index should point to.
|
||||
|
||||
Returns
|
||||
-------
|
||||
QModelIndex
|
||||
The requested index.
|
||||
"""
|
||||
if isinstance(path, PurePath):
|
||||
path = path.parts
|
||||
|
||||
# flat mode
|
||||
if self.mode == self.DisplayMode.FLAT:
|
||||
i = bisect.bisect_left(self._flattened, path)
|
||||
if i < len(self._flattened) and self._flattened[i].path == path:
|
||||
return self.index(i, 0)
|
||||
return QModelIndex()
|
||||
|
||||
# tree mode
|
||||
simplified = self.mode == self.DisplayMode.SIMPLIFIED_TREE
|
||||
|
||||
def step(tup, subpath):
|
||||
index, i, item = tup
|
||||
|
||||
if not item:
|
||||
return index, None
|
||||
|
||||
r, child = item.get(subpath)
|
||||
|
||||
if not child:
|
||||
return QModelIndex(), None
|
||||
|
||||
if i <= -1:
|
||||
i = r
|
||||
|
||||
if (simplified and len(child.children) == 1
|
||||
and self._simplify_filter(child)):
|
||||
return index, i, child
|
||||
|
||||
index = self.index(i if simplified else r, 0, index)
|
||||
|
||||
return index, -1, child
|
||||
|
||||
index, i, item = reduce(step, path, (QModelIndex(), -1, self.root))
|
||||
|
||||
return index
|
||||
|
||||
def index(self, row: int, column: int,
|
||||
parent: QModelIndex = QModelIndex()) -> QModelIndex:
|
||||
"""
|
||||
Construct a `QModelIndex`.
|
||||
|
||||
Returns the index of the item in the model specified by
|
||||
the given row, column and parent index.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
row : int
|
||||
column : int
|
||||
parent : QModelIndex, optional
|
||||
|
||||
Returns
|
||||
-------
|
||||
QModelIndex
|
||||
The requested index.
|
||||
"""
|
||||
# different behavior in flat and treemode
|
||||
if self.mode == self.DisplayMode.FLAT:
|
||||
if (0 <= row < len(self._flattened)
|
||||
and 0 <= column < self.columnCount(parent)):
|
||||
return self.createIndex(row, column, self._flattened[row])
|
||||
|
||||
return QModelIndex()
|
||||
|
||||
# valid index?
|
||||
if not parent.isValid():
|
||||
parent_item: FileSystemItem[T] = self.root
|
||||
else:
|
||||
parent_item = parent.internalPointer()
|
||||
|
||||
item = list(parent_item.children)[row]
|
||||
|
||||
if self.mode == self.DisplayMode.SIMPLIFIED_TREE:
|
||||
# combine items with a single child with that child
|
||||
while len(item.children) == 1 and self._simplify_filter(item):
|
||||
item = item.children[0]
|
||||
|
||||
if (0 <= row < len(parent_item.children)
|
||||
and 0 <= column < self.columnCount(parent)):
|
||||
return self.createIndex(row, column, item)
|
||||
|
||||
return QModelIndex()
|
||||
|
||||
@overload
|
||||
def parent(self, child: QModelIndex) -> QModelIndex:
|
||||
pass
|
||||
|
||||
@overload
|
||||
def parent(self) -> QObject:
|
||||
pass
|
||||
|
||||
def parent(self, child=None):
|
||||
"""
|
||||
Returns the parent of the model item with the given index.
|
||||
|
||||
If the item has no parent, an invalid QModelIndex is returned.
|
||||
A common convention used in models that expose tree data structures
|
||||
is that only items in the first column have children.
|
||||
For that case, when reimplementing this function in a subclass
|
||||
the column of the returned QModelIndex would be 0.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
child : QModelIndex
|
||||
The index of the child item.
|
||||
|
||||
Returns
|
||||
-------
|
||||
QModelIndex
|
||||
The index of the parent item.
|
||||
"""
|
||||
# overloaded variant to retrieve parent of model
|
||||
if child is None:
|
||||
return super().parent()
|
||||
|
||||
# variant to retrieve parent for data item
|
||||
if not child.isValid():
|
||||
return QModelIndex()
|
||||
|
||||
# different behavior in tree and flat mode
|
||||
if self.mode == self.DisplayMode.FLAT:
|
||||
return QModelIndex() # in flat mode their are no parents
|
||||
|
||||
child_item: FileSystemItem[T] = child.internalPointer()
|
||||
parent_item = child_item._parent
|
||||
|
||||
if self.mode == self.DisplayMode.SIMPLIFIED_TREE:
|
||||
# combine items with a single child with the child
|
||||
while (parent_item is not self.root # do not call filter with root
|
||||
and len(parent_item.children) == 1
|
||||
and self._simplify_filter(parent_item)):
|
||||
parent_item = parent_item._parent
|
||||
|
||||
if parent_item is self.root:
|
||||
# Never return root item since it shouldn't be displayed
|
||||
return QModelIndex()
|
||||
|
||||
row, item = parent_item._parent.get(parent_item.subpath)
|
||||
return self.createIndex(row, 0, parent_item)
|
||||
|
||||
def flags(self, index: QModelIndex) -> Qt.ItemFlags:
|
||||
"""
|
||||
Returns the item flags for the given index.
|
||||
|
||||
The base class implementation returns a combination of flags
|
||||
that enables the item (ItemIsEnabled) and
|
||||
allows it to be selected (ItemIsSelectable).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
index : QModelIndex
|
||||
The index.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Qt.ItemFlags
|
||||
The flags.
|
||||
"""
|
||||
return super().flags(index)
|
||||
|
||||
def headerData(self,
|
||||
section: int,
|
||||
orientation: Qt.Orientation,
|
||||
role: int = Qt.ItemDataRole.DisplayRole):
|
||||
"""
|
||||
Get the data for the given role and section in the given header.
|
||||
|
||||
The header is identified by its orientation.
|
||||
For horizontal headers, the section number corresponds to
|
||||
the column number. Similarly, for vertical headers,
|
||||
the section number corresponds to the row number.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
section : int
|
||||
The row or column number.
|
||||
orientation : Qt.Orientation
|
||||
The orientation of the header.
|
||||
role : int, optional
|
||||
The data role, by default Qt.ItemDataRole.DisplayRole
|
||||
|
||||
Returns
|
||||
-------
|
||||
Any
|
||||
The data for the specified header section.
|
||||
"""
|
||||
return super().headerData(section, orientation, role)
|
326
tests/test_treemodel.py
Normal file
326
tests/test_treemodel.py
Normal file
|
@ -0,0 +1,326 @@
|
|||
from pathlib import PurePath
|
||||
|
||||
import pytest
|
||||
from PyQt5.QtCore import QModelIndex
|
||||
|
||||
from vorta.views.partials.treemodel import FileSystemItem, FileTreeModel
|
||||
|
||||
|
||||
class TreeModelImp(FileTreeModel):
|
||||
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
|
||||
return 1
|
||||
|
||||
def _make_filesystemitem(self, path, data):
|
||||
return super()._make_filesystemitem(path, data)
|
||||
|
||||
def _merge_data(self, item, data):
|
||||
return super()._merge_data(item, data)
|
||||
|
||||
|
||||
class TestFileSystemItem:
|
||||
def test_tuple(self):
|
||||
item = FileSystemItem(PurePath('test').parts, 0)
|
||||
|
||||
assert item[0] == item.path
|
||||
assert item[1] == item.data
|
||||
|
||||
def test_add(self):
|
||||
item = FileSystemItem(PurePath('test').parts, 0)
|
||||
|
||||
assert len(item.children) == 0
|
||||
|
||||
child = FileSystemItem(PurePath('test/hello').parts, 4)
|
||||
|
||||
item.add(child)
|
||||
|
||||
assert len(item.children) == 1
|
||||
assert item.children[0] == child
|
||||
assert child.subpath == 'hello'
|
||||
assert child._parent == item
|
||||
|
||||
child = FileSystemItem(PurePath('test/hello').parts, 8)
|
||||
with pytest.raises(RuntimeError):
|
||||
# may not add a child with the same subpath
|
||||
item.add(child)
|
||||
|
||||
def test_remove(self):
|
||||
item = FileSystemItem(PurePath('test').parts, 0)
|
||||
child1 = FileSystemItem(PurePath('test/a').parts, 4)
|
||||
child2 = FileSystemItem(PurePath('test/b').parts, 3)
|
||||
child3 = FileSystemItem(PurePath('test/c').parts, 2)
|
||||
|
||||
item.add(child1)
|
||||
item.add(child2)
|
||||
item.add(child3)
|
||||
|
||||
assert len(item.children) == 3
|
||||
|
||||
# test remove subpath
|
||||
item.remove('b')
|
||||
assert len(item.children) == 2
|
||||
assert child2 not in item.children
|
||||
|
||||
# test remove item
|
||||
item.remove(child3)
|
||||
assert len(item.children) == 1
|
||||
assert child3 not in item.children
|
||||
|
||||
def test_get(self):
|
||||
item = FileSystemItem(PurePath('test').parts, 0)
|
||||
child1 = FileSystemItem(PurePath('test/a').parts, 4)
|
||||
child2 = FileSystemItem(PurePath('test/b').parts, 3)
|
||||
child3 = FileSystemItem(PurePath('test/c').parts, 2)
|
||||
|
||||
item.add(child1)
|
||||
item.add(child2)
|
||||
item.add(child3)
|
||||
|
||||
# test get inexistent subpath
|
||||
assert item.get('unknown') is None
|
||||
assert item.get('unknown', default='default') == 'default'
|
||||
|
||||
# get subpath
|
||||
res = item.get('a')
|
||||
assert res is not None
|
||||
assert res[1] == child1
|
||||
|
||||
res = item.get('b')
|
||||
assert res is not None
|
||||
assert res[1] == child2
|
||||
|
||||
# get subpath of empty list
|
||||
assert child1.get('a') is None
|
||||
|
||||
def test_get_subpath(self):
|
||||
item = FileSystemItem(('test',), 0)
|
||||
child1 = FileSystemItem(PurePath('test/a').parts, 4)
|
||||
child2 = FileSystemItem(PurePath('test/b').parts, 3)
|
||||
child3 = FileSystemItem(PurePath('test/c').parts, 2)
|
||||
|
||||
item.add(child1)
|
||||
item.add(child2)
|
||||
item.add(child3)
|
||||
|
||||
child11 = FileSystemItem(PurePath('test/a/aa').parts, 4)
|
||||
child1.add(child11)
|
||||
|
||||
assert item.get_path(PurePath('a/aa').parts) is child11
|
||||
assert item.get_path(('b',)) is child2
|
||||
|
||||
|
||||
class TestFileTreeModel:
|
||||
def test_basic_setup(self):
|
||||
model = TreeModelImp()
|
||||
|
||||
assert model.rowCount() == 0
|
||||
|
||||
# test FileTreeModel.addItem
|
||||
model.addItem((('test',), 0))
|
||||
assert model.rowCount() == 1
|
||||
|
||||
item = model.getItem(('test',))
|
||||
assert item is not None
|
||||
assert item.subpath == 'test'
|
||||
assert len(item.children) == 0
|
||||
assert item.data == 0 # test id
|
||||
|
||||
model.addItem((PurePath('/hello'), 1))
|
||||
model.addItem(FileSystemItem(PurePath('/hello/test').parts, 2))
|
||||
|
||||
assert model.rowCount() == 2
|
||||
|
||||
item = model.getItem(('/',))
|
||||
assert item is not None
|
||||
assert item.subpath == '/'
|
||||
assert len(item.children) == 1
|
||||
|
||||
item = model.getItem(PurePath('/hello/test').parts)
|
||||
assert item is not None and item.data == 2
|
||||
|
||||
# test adding Item to existing parent
|
||||
model.addItem((PurePath('test/subtest'), 3))
|
||||
assert model.rowCount() == 2
|
||||
item = model.getItem(PurePath('test/subtest').parts)
|
||||
assert item is not None and item.data == 3
|
||||
|
||||
# test parent
|
||||
assert (model.parent(model.indexPath(
|
||||
PurePath('test/subtest'))) == model.indexPath(PurePath('test')))
|
||||
|
||||
# test index
|
||||
item1 = model.getItem(('test',))
|
||||
item2 = model.getItem(PurePath('test/subtest'))
|
||||
|
||||
index1 = model.index(1, 0)
|
||||
assert index1.internalPointer() == item1
|
||||
assert index1 == model.indexPath(PurePath('test'))
|
||||
index2 = model.index(0, 0, index1)
|
||||
assert index2.internalPointer() == item2
|
||||
assert index2 == model.indexPath(PurePath('test/subtest'))
|
||||
|
||||
# test rowCount
|
||||
assert model.rowCount() == 2
|
||||
assert model.rowCount(index1) == 1
|
||||
|
||||
# test remove
|
||||
model.removeItem(('test',))
|
||||
assert model.rowCount() == 1
|
||||
assert item1 not in model.root.children
|
||||
|
||||
model.removeItem(PurePath('/hello/test').parts)
|
||||
assert model.rowCount() == 1
|
||||
assert model.getItem(PurePath('/hello/test')) is None
|
||||
item3 = model.getItem(PurePath('/hello'))
|
||||
assert len(item3.children) == 0
|
||||
|
||||
def test_root(self):
|
||||
model = TreeModelImp()
|
||||
assert model.getItem(PurePath()) == model.root
|
||||
|
||||
def test_flat(self):
|
||||
items = [
|
||||
(PurePath('a'), 0),
|
||||
(PurePath('a/a'), 1),
|
||||
(PurePath('a/c'), 3),
|
||||
(PurePath('a/b/a'), 4),
|
||||
(PurePath('a/b/b'), 5),
|
||||
(PurePath('a/b'), 2),
|
||||
(PurePath('b'), 6),
|
||||
(PurePath('b/a'), 7),
|
||||
(PurePath('b/b'), 8),
|
||||
]
|
||||
|
||||
model = TreeModelImp()
|
||||
model.addItems(items)
|
||||
|
||||
# test flat representation
|
||||
model.setMode(model.DisplayMode.FLAT)
|
||||
|
||||
assert model.rowCount() == len(items)
|
||||
assert model.parent(model.index(4, 0)) == QModelIndex()
|
||||
assert model.rowCount(model.index(3, 0)) == 0
|
||||
|
||||
item = model.getItem(PurePath('a/b/a'))
|
||||
assert item is not None and item.data == 4
|
||||
|
||||
# test flat add
|
||||
model.addItem((PurePath('a/a/a'), 10))
|
||||
|
||||
assert model.rowCount() == len(items) + 1
|
||||
item = model.getItem(PurePath('a/a/a'))
|
||||
assert item is not None and item.data == 10
|
||||
|
||||
# test flat remove
|
||||
model.removeItem(PurePath('a/a/a'))
|
||||
|
||||
assert model.rowCount() == len(items)
|
||||
assert item not in model._flattened
|
||||
|
||||
# test flat indexPath
|
||||
index = model.indexPath(PurePath('a/b'))
|
||||
assert index.internalPointer().data == 2
|
||||
assert model._flattened[index.row()].data == 2
|
||||
assert index.parent() == QModelIndex()
|
||||
|
||||
# test
|
||||
model.setMode(model.DisplayMode.TREE)
|
||||
|
||||
assert model.rowCount() == 2
|
||||
|
||||
def test_simplified_tree(self):
|
||||
items = [
|
||||
(PurePath('a'), 0),
|
||||
(PurePath('a/a'), 1),
|
||||
(PurePath('a/c'), 3),
|
||||
(PurePath('a/b/a'), 4),
|
||||
(PurePath('a/b/b'), 5),
|
||||
(PurePath('a/b'), 2),
|
||||
(PurePath('b'), 6),
|
||||
(PurePath('b/a'), 7),
|
||||
(PurePath('b/b'), 8),
|
||||
(PurePath('c'), 9),
|
||||
(PurePath('c/a'), 10),
|
||||
(PurePath('c/a/b'), 11),
|
||||
(PurePath('c/a/b/c'), 12),
|
||||
(PurePath('c/a/b/a'), 13),
|
||||
(PurePath('c/a/b/a/b/c'), 14),
|
||||
(PurePath('c/a/b/b/c/a'), 15),
|
||||
]
|
||||
|
||||
model = TreeModelImp()
|
||||
model.addItems(items)
|
||||
|
||||
# test tree representation
|
||||
model.setMode(model.DisplayMode.SIMPLIFIED_TREE)
|
||||
|
||||
assert model.rowCount() == 3
|
||||
|
||||
a = model.index(0, 0)
|
||||
assert model.rowCount(a) == 3
|
||||
ab = model.index(1, 0, a)
|
||||
assert model.rowCount(ab) == 2
|
||||
assert model.parent(ab) == a
|
||||
|
||||
# test combined representation
|
||||
|
||||
cab = model.index(2, 0)
|
||||
assert model.rowCount(cab) == 3
|
||||
assert cab.internalPointer().data == 11
|
||||
assert model.rowCount(cab) == 3
|
||||
assert model.parent(cab) == QModelIndex()
|
||||
cabc = model.index(2, 0, cab)
|
||||
cababc = model.index(0, 0, cab)
|
||||
cabbca = model.index(1, 0, cab)
|
||||
assert cababc.internalPointer().data == 14
|
||||
assert model.parent(cababc).internalId() == cab.internalId()
|
||||
assert cabbca.internalPointer().data == 15
|
||||
assert model.parent(cabbca).internalId() == cab.internalId()
|
||||
assert cabc.internalPointer().data == 12
|
||||
|
||||
# test combined add
|
||||
model.addItem((PurePath('a/a/a'), 30))
|
||||
|
||||
aaa = model.index(0, 0, a)
|
||||
assert aaa.internalPointer().data == 30
|
||||
assert model.rowCount(aaa) == 0
|
||||
|
||||
model.addItem((PurePath('c/a/a'), 31))
|
||||
|
||||
ca = model.index(2, 0)
|
||||
assert ca.internalPointer().data == 10
|
||||
assert ca.parent() == QModelIndex()
|
||||
assert model.rowCount(ca) == 2
|
||||
caa = model.index(0, 0, ca)
|
||||
assert caa.internalPointer().data == 31
|
||||
assert caa.parent().internalId() == ca.internalId()
|
||||
|
||||
# test combined remove
|
||||
model.removeItem(PurePath('a/a/a').parts)
|
||||
|
||||
aa = model.index(0, 0, a)
|
||||
assert aa.internalPointer().data == 1
|
||||
assert model.rowCount(aa) == 0
|
||||
|
||||
model.removeItem(PurePath('c/a/a'))
|
||||
|
||||
cab = model.index(2, 0)
|
||||
assert cab.internalPointer().data == 11
|
||||
assert model.rowCount(cab) == 3
|
||||
|
||||
# test combined indexPath
|
||||
index = model.indexPath(PurePath('a/b'))
|
||||
assert index.internalPointer().data == 2
|
||||
assert index.parent() == a
|
||||
|
||||
index = model.indexPath(PurePath('c/a/b'))
|
||||
assert model.parent(index) == QModelIndex()
|
||||
assert index.internalPointer().data == 11
|
||||
assert model.rowCount(index) == 3
|
||||
|
||||
index = model.indexPath(PurePath('c/a'))
|
||||
assert index == QModelIndex()
|
||||
|
||||
# test mode change
|
||||
model.setMode(model.DisplayMode.TREE)
|
||||
|
||||
assert model.rowCount() == 3
|
Loading…
Add table
Reference in a new issue