Issue
I want to add the ability to keep any number of leaves within a QTreeWidget or QTreeView visible even when the parent is folded. This will give the user the ability to keep only the information they're interested in visible while hiding other information.
How can I implement this effect?
import sys
from PySide6.QtWidgets import QApplication, QMainWindow, QTreeWidget, QTreeWidgetItem
data = {
"Module 1": {
"Sub-Module 1": {
"Leaf 1": "Leaf 1 Info",
"Leaf 2": "Leaf 2 Info",
"Leaf 3": "Leaf 3 Info",
"Leaf 4": "Leaf 4 Info",
},
"Sub-Module 2": {
"Leaf 5": "Leaf 5 Info",
"Leaf 6": "Leaf 6 Info",
"Leaf 7": "Leaf 7 Info",
"Leaf 8": "Leaf 8 Info",
},
},
"Module 2": {
"Sub-Module 3": {
"Leaf 9": "Leaf 9 Info",
"Leaf 10": "Leaf 10 Info",
"Leaf 11": "Leaf 11 Info",
"Leaf 12": "Leaf 12 Info",
},
},
}
class TreeWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setGeometry(300, 300, 300, 400)
tw = QTreeWidget(self)
tw.setColumnCount(2)
tw.setAlternatingRowColors(True)
tw.setHeaderLabels(["Name", "Description"])
self.fill_tree(tw, data)
self.setCentralWidget(tw)
tw.expandAll()
tw.resizeColumnToContents(0)
def fill_tree(self, widg, data) -> None:
for key, val in data.items():
if isinstance(val, str):
tw_item = QTreeWidgetItem([key, val])
keep_going = False
else:
tw_item = QTreeWidgetItem([key])
keep_going = True
if isinstance(widg, QTreeWidget):
widg.addTopLevelItem(tw_item)
else:
widg.addChild(tw_item)
if keep_going:
self.fill_tree(tw_item, val)
if __name__ == "__main__":
app = QApplication(sys.argv)
window = TreeWindow()
window.show()
sys.exit(app.exec())
By way of example, if the above code were run and "Leaf 3" were selected to be "always shown" but the rest of the tree was folded, the following is the information desired to be displayed.
|-- Module 1
| `-- Sub-Module 1
| `-- Leaf 3
`-- Module 2
Solution
Well, that's not easy to achieve.
The most important aspect to consider is that a tree view has a conventional and expected behavior that users expect: when you "fold" (the proper term is collapse) an item, the user expects that all its children (including [great-]grand children) are collapsed as well: only the parent is expected to be visible.
Luckily, Qt is quite extensible and allows us to find some work arounds, but they are not cost-free.
The most important problem is that the user (righteously) expects to see a visible hint that shows whether an item is expanded or collapsed. Qt, in its "simplicity", just assumes that aspect based on the current view state, meaning that the "expanding decoration" will be shown according to the fact that a parent has (and shows) its children or not.
Then, there is a conceptual problem in this request: what if one of the children is "collapsed", but any of the other is not? What should the parent show? And what if the parent is "expanded", but all its children are not?
That's another reason for which, considering UX aspects, such interface is normally not provided (nor desired) in a generic toolkit implementation.
Still, we want that behavior... so?
One possibility is to work around all these problems, which requires us to take care of many aspects:
- ensure that the "virtual" collapsed/expanded state is persistent, based on the parent;
- override the drawing, possibly using a QProxyStyle;
- use recursive functions that take care of the expanded/collapsed state of a parent;
- take care of user interaction;
With the above in mind, here is a possible (though, imperfect) solution:
import sys
from PyQt5.QtCore import *
from PyQt5.QtWidgets import *
data = {
# ... as above
}
ExpandedRole = Qt.UserRole + 100 # a custom role to "override" the parent state
class ProxyStyle(QProxyStyle):
_childIndex = None
def drawPrimitive(self, elem, opt, qp, widget=None):
if elem == self.PE_IndicatorBranch and self._childIndex:
# use the _childIndex reference for the *first* call to
# drawPrimitive only, then revert to the default painting
if self._childIndex.model().hasChildren(self._childIndex):
opt.state |= self.State_Children
if not self._childIndex.data(ExpandedRole):
opt.state &= ~self.State_Open
self._childIndex = None
super().drawPrimitive(elem, opt, qp, widget)
class ProxyStyle(QProxyStyle):
_childIndex = None
def drawPrimitive(self, elem, opt, qp, widget=None):
if elem == self.PE_IndicatorBranch and self._childIndex:
if self._childIndex.model().hasChildren(self._childIndex):
opt.state |= self.State_Children
if not self._childIndex.data(ExpandedRole):
opt.state &= ~self.State_Open
self._childIndex = None
super().drawPrimitive(elem, opt, qp, widget)
class TreeWidget(QTreeWidget):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setAnimated(False)
# override the internal collapsed signal
self.collapsed.connect(self.expand)
# do the same for internal calls for expanding
self.expanded.connect(lambda index: self.setExpanded(index, True))
def drawBranches(self, qp, rect, index):
# set a "reference" index for the style, necessary for the indentation
self.style()._childIndex = index
super().drawBranches(qp, rect, index)
def isCheckedRecursive(self, index):
# tell if an item or any of its children is checked (not "hideable")
if index.data(Qt.CheckStateRole):
return True
model = index.model()
for row in range(model.rowCount(index)):
childIndex = model.index(row, 0, index)
if self.isCheckedRecursive(childIndex):
return True
return False
def setExpanded(self, parent, expanded):
# an overridden "slot"
model = self.model()
if not model.hasChildren(parent):
return False
wasExpanded = parent.data(ExpandedRole)
if expanded == wasExpanded:
return wasExpanded
model.setData(parent, expanded, ExpandedRole)
r = self.visualRect(parent)
r.setLeft(0)
self.viewport().update(r)
hasExpanded = False
for row in range(model.rowCount(parent)):
childIndex = model.index(row, 0, parent)
if self.setExpanded(childIndex, expanded):
hasExpanded = True
elif expanded:
self.setRowHidden(row, parent, False)
else:
self.setRowHidden(row, parent,
not self.isCheckedRecursive(childIndex))
return hasExpanded
def toggleExpanded(self, index):
expand = index.data(ExpandedRole)
if expand is not None:
expand = not expand
else:
for r in range(self.model().rowCount(index)):
if self.isRowHidden(r, index):
expand = True
break
else:
expand = False
self.setExpanded(index, expand)
return expand
def hasExpandArrowAtPos(self, pos):
# if the mouse position is in the "arrow" rect, return the index
index = self.indexAt(pos)
if not index.isValid() or not self.model().hasChildren(index):
return
parent = index.parent()
rootDecorated = self.rootIsDecorated()
if not parent.isValid() and not rootDecorated:
return
indent = self.indentation()
if rootDecorated:
itemIndent = 0
else:
itemIndent = -indent
while parent.isValid():
itemIndent += indent
parent = parent.parent()
position = self.header().sectionViewportPosition(0)
itemRect = self.visualRect(index)
rect = QRect(position + itemIndent, itemRect.y(),
indent, itemRect.height())
opt = QStyleOption()
opt.initFrom(self)
opt.rect = rect
arrowRect = self.style().subElementRect(
QStyle.SE_TreeViewDisclosureItem, opt, self)
if arrowRect.contains(pos):
return index
def mousePressEvent(self, event):
if event.button() == Qt.LeftButton:
index = self.hasExpandArrowAtPos(event.pos())
if index:
self.toggleExpanded(index)
self.setCurrentIndex(index)
return
super().mousePressEvent(event)
class TreeWindow(QMainWindow):
# ...
def fill_tree(self, widg, data) -> None:
for key, val in data.items():
# ...
if keep_going:
self.fill_tree(tw_item, val)
else:
tw_item.setCheckState(0, Qt.Checked)
Note that the above implementation is not perfect.
For instance:
- it doesn't consider double clicking a parent
- it doesn't properly work with keyboard navigation; if you want to implement that aspect, you must do it within a
moveCursor
override, consider the current index and viewport scroll position, eventually expand/collapse the parent or current item and return the new resulting index, otherwise just revert to the default implementation; - some internal functions and behaviors might not work as expected, such as direct calls to
expand()
,collapse()
,expandToDepth()
orexpandRecursively()
; - as mentioned above, it's not a good choice from the UX perspective: it's not really intuitive, and the parent/grandparent inconsistency might be very confusing to the user;
Frankly, while I can see the need for such a behavior, I would not really suggest it. The UX aspect is what most worries me, because it's not intuitive: if I want to collapse an item, I don't expect it to still show its children.
A better solution, instead, would be to use a proxy model and a specifically dedicated UI element to toggle the visible state that would filter the defined items: it would then just be a matter of calling invalidateFilter()
and implementing filterAcceptsRow()
using a common function that checks whether an item (or any of its [grand]children) should be shown or not.
Answered By - musicamante
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.