The example code below generates a GUI like this:
What I would like, is to prepend a row count before the main entries only (a, b and c in the image), as shown on the manually edited image:
How can I do that – without changing the data / data model? (I guess I’d prefer a whole new “virtual column” being inserted, but I guess some hack that just prepends a label to the expand/collapse icon/handle might work just as well …)
# https://gist.github.com/nbassler/342fc56c42df27239fa5276b79fca8e6
"""
Reworked code based on
http://trevorius.com/scrapbook/uncategorized/pyqt-custom-abstractitemmodel/
Adapted to Qt5 and fixed column/row bug.
TODO: handle changing data.
"""
import sys
from PyQt5 import QtCore, QtWidgets
class CustomNode(object):
def __init__(self, data):
self._data = data
if type(data) == tuple:
self._data = list(data)
if type(data) is str or not hasattr(data, '__getitem__'):
self._data = [data]
self._columncount = len(self._data)
self._children = []
self._parent = None
self._row = 0
def data(self, column):
if column >= 0 and column < len(self._data):
return self._data[column]
def columnCount(self):
return self._columncount
def childCount(self):
return len(self._children)
def child(self, row):
if row >= 0 and row < self.childCount():
return self._children[row]
def parent(self):
return self._parent
def row(self):
return self._row
def addChild(self, child):
child._parent = self
child._row = len(self._children)
self._children.append(child)
self._columncount = max(child.columnCount(), self._columncount)
class CustomModel(QtCore.QAbstractItemModel):
def __init__(self, nodes):
QtCore.QAbstractItemModel.__init__(self)
self._root = CustomNode(None)
for node in nodes:
self._root.addChild(node)
def rowCount(self, index):
if index.isValid():
return index.internalPointer().childCount()
return self._root.childCount()
def addChild(self, node, _parent):
if not _parent or not _parent.isValid():
parent = self._root
else:
parent = _parent.internalPointer()
parent.addChild(node)
def index(self, row, column, _parent=None):
if not _parent or not _parent.isValid():
parent = self._root
else:
parent = _parent.internalPointer()
if not QtCore.QAbstractItemModel.hasIndex(self, row, column, _parent):
return QtCore.QModelIndex()
child = parent.child(row)
if child:
return QtCore.QAbstractItemModel.createIndex(self, row, column, child)
else:
return QtCore.QModelIndex()
def parent(self, index):
if index.isValid():
p = index.internalPointer().parent()
if p:
return QtCore.QAbstractItemModel.createIndex(self, p.row(), 0, p)
return QtCore.QModelIndex()
def columnCount(self, index):
if index.isValid():
return index.internalPointer().columnCount()
return self._root.columnCount()
def data(self, index, role):
if not index.isValid():
return None
node = index.internalPointer()
if role == QtCore.Qt.DisplayRole:
return node.data(index.column())
return None
class MyTree():
"""
"""
def __init__(self):
self.items = []
# Set some random data:
for i in 'abc':
self.items.append(CustomNode(i))
self.items[-1].addChild(CustomNode(['d', 'e', 'f']))
self.items[-1].addChild(CustomNode(['g', 'h', 'i']))
self.tw = QtWidgets.QTreeView()
self.tw.setModel(CustomModel(self.items))
def add_data(self, data):
"""
TODO: how to insert data, and update tree.
"""
# self.items[-1].addChild(CustomNode(['1', '2', '3']))
# self.tw.setModel(CustomModel(self.items))
if __name__ == "__main__":
app = QtWidgets.QApplication(sys.argv)
mytree = MyTree()
mytree.tw.show()
sys.exit(app.exec_())
OK, I think I’ve got it solved, based on the solution in Add additional information to items in a QTreeView/QFileSystemModel :
- Have the
CustomModel
return a “hacked” columnCount() value, increased by 1 from the actual column count of the data - In
CustomModel.data()
, check if we’re in the last “hacked”/added column, if so, determine whether the node is “first level” (could not find another way for this, other than passing a reference to the holder of the data, hereMyTree
, toCustomModel
– so that we can retrieve the dataMyTree.items
inside it), if it is, output row number (else output nothing) - In
CustomModel.data()
, also check viasection
if we’re in the last “hacked”/added column, if so, output#
- Wherever the
.setModel()
is run on the table view, after that, add.moveSection
to visually move the “hacked”/added column elsewhere – in this case, at the beginning (leftmost position), which is column index 0.
With that, I get code that generates this GUI:
… which is exactly what I wanted (the new first column is a bit too wide now, but I guess that is not too hard to fix).
It was tricky to apply the original solution from the cited answer, because they seemingly call just columnCount()
, for which I was getting the error “CustomModel.columnCount() missing 1 required positional argument: ‘index'”.
# https://gist.github.com/nbassler/342fc56c42df27239fa5276b79fca8e6
'''
Reworked code based on
http://trevorius.com/scrapbook/uncategorized/pyqt-custom-abstractitemmodel/
Adapted to Qt5 and fixed column/row bug.
TODO: handle changing data.
'''
import sys
from PyQt5 import QtCore, QtWidgets
class CustomNode(object):
def __init__(self, data):
self._data = data
if type(data) == tuple:
self._data = list(data)
if type(data) is str or not hasattr(data, '__getitem__'):
self._data = [data]
self._columncount = len(self._data)
self._children = []
self._parent = None
self._row = 0
def data(self, column):
if column >= 0 and column < len(self._data):
return self._data[column]
def columnCount(self):
return self._columncount
def childCount(self):
return len(self._children)
def child(self, row):
if row >= 0 and row < self.childCount():
return self._children[row]
def parent(self):
return self._parent
def row(self):
return self._row
def addChild(self, child):
child._parent = self
child._row = len(self._children)
self._children.append(child)
self._columncount = max(child.columnCount(), self._columncount)
class CustomModel(QtCore.QAbstractItemModel):
def __init__(self, dataparent, nodes):
QtCore.QAbstractItemModel.__init__(self)
self._root = CustomNode(None)
self._dataparent = dataparent
for node in nodes:
self._root.addChild(node)
def rowCount(self, index):
if index.isValid():
return index.internalPointer().childCount()
return self._root.childCount()
def addChild(self, node, _parent):
if not _parent or not _parent.isValid():
parent = self._root
else:
parent = _parent.internalPointer()
parent.addChild(node)
def index(self, row, column, _parent=None):
if not _parent or not _parent.isValid():
parent = self._root
else:
parent = _parent.internalPointer()
if not QtCore.QAbstractItemModel.hasIndex(self, row, column, _parent):
return QtCore.QModelIndex()
child = parent.child(row)
if child:
return QtCore.QAbstractItemModel.createIndex(self, row, column, child)
else:
return QtCore.QModelIndex()
def parent(self, index):
if index.isValid():
p = index.internalPointer().parent()
if p:
return QtCore.QAbstractItemModel.createIndex(self, p.row(), 0, p)
return QtCore.QModelIndex()
def columnCount(self, index):
hack = 1 # /q/46835109
if index.isValid():
return index.internalPointer().columnCount() +hack
return self._root.columnCount() +hack
def data(self, index, role):
extra = False
if not index.isValid():
return None
extra = index.column() == self.columnCount(index.parent()) - 1 # /q/46835109 ; no +hack here, else no match!
node = index.internalPointer()
is_first_level = (node in self._dataparent.items)
if role == QtCore.Qt.DisplayRole:
if extra and is_first_level:
return "????{}".format(index.row()+1) # 1-based count
else:
return node.data(index.column())
return None
def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole): # /q/64287713
if (orientation == QtCore.Qt.Horizontal and role == QtCore.Qt.DisplayRole):
if section == self.columnCount(QtCore.QModelIndex()) - 1:
return '#'
return super().headerData(section, orientation, role)
class MyTree():
'''
'''
def __init__(self):
self.items = []
# Set some random data:
for i in 'abc':
self.items.append(CustomNode(i))
self.items[-1].addChild(CustomNode(['d', 'e', 'f']))
self.items[-1].addChild(CustomNode(['g', 'h', 'i']))
self.tw = QtWidgets.QTreeView()
self.tw.setModel(CustomModel(self, self.items))
last_column_idx = self.tw.model().columnCount(QtCore.QModelIndex()) - 1
self.tw.header().moveSection(last_column_idx, 0) # /q/46835109 "Moves the section at visual index `from` to occupy visual index `to`"
def add_data(self, data):
'''
TODO: how to insert data, and update tree.
'''
# self.items[-1].addChild(CustomNode(['1', '2', '3']))
# self.tw.setModel(CustomModel(self.items))
if __name__ == "__main__":
app = QtWidgets.QApplication(sys.argv)
mytree = MyTree()
mytree.tw.show()
sys.exit(app.exec_())
1
While the currently proposed solution may work for the OP, it has substantial issues. Most importantly, it alters the model structure only for display purposes, which is generally discouraged:
- the virtual column doesn’t really contain useful data;
- access to actual indexes becomes inconsistent (the real first column is not the first visible one);
- it may present issues for further implementation (such as proxy models); consider checking it with QAbstractItemModelTester;
- could present issues for selections or whenever the view should eventually span columns for parent items having less columns;
A more ideal approach would be to leave the model untouched, and imitate what QTableView does, which is adding a vertical header.
In order to achieve this, we need to:
- create a vertical QHeaderView as child of the tree view;
- ensure that the header is properly scrolled along with the viewport;
- override the view’s
updateGeometries()
, in which we:- call the base implementation;
- add a left margin for the header;
- set its geometry appropriately;
- resize each header section considering expanded rows;
Since the text has to be properly aligned with the top level item, and using Qt.AlignTop
would show the text slightly above that, we probably need a custom QHeaderView so that we can override its paintSection()
; in the following example I provide 3 alternatives for painting: the first one just tries to shift the section rect, but that may not be ideal as the section separator may be shown off the actual section start; the second is extremely basic and just paints the section text; the third mimics the default behavior as close as possible using QStyle features.
class VerticalTreeHeader(QHeaderView):
def __init__(self, parent):
super().__init__(Qt.Vertical, parent)
self.setSectionResizeMode(QHeaderView.Fixed)
# in case the default minimum is too big
self.setMinimumSectionSize(1)
# only necessary for the "translatePaintSection"
self.setDefaultAlignment(Qt.AlignHCenter | Qt.AlignTop)
# change the following with the alternative methods to test them
self.paintSection = self.stylePaintSection
def translatePaintSection(self, qp, rect, index):
baseHeight = self.parent().rowHeight(
self.model().index(index, 0, self.rootIndex()))
fmHeight = qp.fontMetrics().height()
dy = (baseHeight - fmHeight) // 2
rect.translate(0, dy)
super().paintSection(qp, rect, index)
def textPaintSection(self, qp, rect, index):
baseHeight = self.parent().rowHeight(
self.model().index(index, 0, self.rootIndex()))
rect.setHeight(baseHeight)
qp.drawText(rect, Qt.AlignCenter, str(index + 1))
def stylePaintSection(self, qp, rect, index):
opt = QStyleOptionHeader()
self.initStyleOption(opt)
opt.rect = rect
opt.section = index
# this doesn't check for hidden rows, consider implementing it
isFirst = index == 0
isLast = index == self.model().rowCount(self.rootIndex()) - 1
if isFirst and isLast:
opt.position = opt.OnlyOneSection
elif isFirst:
opt.position = opt.Beginning
elif isLast:
opt.position = opt.End
else:
opt.position = opt.Middle
# draw the header with *no* text
self.style().drawControl(QStyle.CE_Header, opt, qp, self)
rowHeight = self.parent().rowHeight(
self.model().index(index, 0, self.rootIndex()))
qp.drawText(
rect.x(), rect.y(), rect.width(), rowHeight,
Qt.AlignCenter, str(index + 1)
)
class VerticalHeaderTreeView(QTreeView):
def __init__(self):
super().__init__()
self.dummyColumnHeader = QLabel('#', self, alignment=Qt.AlignCenter)
self.vheader = VerticalTreeHeader(self)
self.verticalScrollBar().valueChanged.connect(self.scrollVHeader)
def scrollVHeader(self):
self.vheader.setOffset(self.verticalOffset())
def setRootIndex(self, index):
super().setRootIndex(index)
self.vheader.setRootIndex(index)
def setModel(self, model):
super().setModel(model)
self.vheader.setModel(model)
def updateGeometries(self):
super().updateGeometries()
width = min(
self.vheader.maximumWidth(),
max(
self.vheader.minimumWidth(),
self.vheader.sizeHint().width(),
# an arbitrary width based on the font
self.fontMetrics().horizontalAdvance(' 000 ')
)
)
margins = self.viewportMargins()
margins.setLeft(width)
self.setViewportMargins(margins)
vg = self.viewport().geometry()
headerGeo = QRect(
vg.left() - width, vg.top(), width, vg.height()
)
self.vheader.setGeometry(headerGeo)
self.dummyColumnHeader.setGeometry(QRect(
QPoint(headerGeo.x(), self.header().y()),
QPoint(headerGeo.right() + 1, headerGeo.y())
))
# get the first visible index (we could assume it's 0, but
# better safe than sorry
root = self.rootIndex()
for row in range(self.model().rowCount(root)):
if not self.isRowHidden(row, root):
index = self.model().index(row, 0, root)
break
else:
return
# navigate down the whole visible tree, item by item
row = index.row()
height = self.rowHeight(index)
while True:
index = self.indexBelow(index)
if index.parent() == root or not index.isValid():
# resize the *previous* top level row
self.vheader.resizeSection(row, height)
if not index.isValid():
break
height = self.rowHeight(index)
row = index.row()
else:
# a child, add its height
height += self.rowHeight(index)
Note that your model implementation has a bug in parent()
: if the parent is the root, you should not use createIndex()
, but return a QModelIndex()
instead:
def parent(self, index):
if index.isValid():
p = index.internalPointer().parent()
if p and p != self._root:
return super().createIndex(p.row(), 0, p)
return QModelIndex()
I suggest you to fix it in your question too.