最近做了个ui需要用到树形结构(本来该用TreeView的,一开始图简单用了TreeWidget,后悔中…)
同时这也可以作为问问题的标准模板(在
StackOverflow
问或是QQ群里问或是去#pyqt问),lz把所有折腾的例子都放在github的
gists
里了(以提升逼格).(唉,直接贴gists行号对不上,免费
wordpress.com
没法自定义css或是javascript…)
默认问问题模板
在此
#!/usr/bin/env python2
import os
import sys
import re
from PyQt4 import QtGui, QtCore
from PyQt4.QtCore import Qt, QString
class TheUI(QtGui.QDialog):
def __init__(self, args=None, parent=None):
super(TheUI, self).__init__(parent)
self.layout1 = QtGui.QVBoxLayout(self)
treeWidget = QtGui.QTreeWidget()
# treeWidget.setSelectionMode( QtGui.QAbstractItemView.ExtendedSelection )
button1 = QtGui.QPushButton('Add')
button2 = QtGui.QPushButton('Add Child')
self.layout1.addWidget(treeWidget)
self.layout2 = QtGui.QHBoxLayout()
self.layout2.addWidget(button1)
self.layout2.addWidget(button2)
self.layout1.addLayout(self.layout2)
treeWidget.setHeaderHidden(True)
self.treeWidget = treeWidget
self.button1 = button1
self.button2 = button2
self.button1.clicked.connect(lambda *x: self.addCmd())
self.button2.clicked.connect(lambda *x: self.addChildCmd())
HEADERS = ( "script", "chunksize", "mem" )
self.treeWidget.setHeaderLabels(HEADERS)
self.treeWidget.setColumnCount( len(HEADERS) )
self.treeWidget.setColumnWidth(0,160)
self.treeWidget.header().show()
self.treeWidget.setDragDropMode(QtGui.QAbstractItemView.InternalMove)
self.resize(500,500)
for i in xrange(6):
item =self.addCmd(i)
if i in (3,4):
self.addChildCmd()
if i==4:
self.addCmd('%s-2' % i,parent=item)
self.treeWidget.expandAll()
self.setStyleSheet("QTreeWidget::item{ height: 30px; }")
def addChildCmd(self):
parent = self.treeWidget.currentItem()
self.addCmd(parent=parent)
self.treeWidget.setCurrentItem(parent)
def addCmd(self, i=None,parent=None):
'add a level to tree widget'
root = self.treeWidget.invisibleRootItem()
if not parent:
parent=root
if i is None:
if parent == root:
i = self.treeWidget.topLevelItemCount()
else:
i = str(parent.text(0))[7:]
i = '%s-%s' %(i,parent.childCount()+1)
item = QtGui.QTreeWidgetItem(parent,['script %s' %i,'1','150'])
self.treeWidget.setCurrentItem(item)
self.treeWidget.expandAll()
return item
if __name__ == '__main__':
app = QtGui.QApplication(sys.argv)
gui = TheUI()
gui.show()
app.exec_()
此例子开了
多选
(特意先注释掉了,因为不好处理拖拽),拖拽,下面两个按钮可以加item
Dragging Indicator 自定义
遇到的第一个想自定义的地方是不想显示放手之前的dragging indicator(也就是被拖动的那行的截图示意),因为这个ui最后有很多嵌套的树形结构,如果显示那一大条蓝色的截图,很难看到放手的位置具体在哪里(用户不知道何时放手,才能放到他想放的位置,黑色横线可能会被那长条挡住)
此时有两种解决方案
想办法把拖动的蓝条搞成透明的(这条在公司里似乎无法实现,似乎一定需要系统开有Compositing Manager才行)此处是疑惑之处,公司的CentOS 6.5没法透明,家里的7.0可以,但是我在家里执行下面代码,也是False(难道说明透明支持与否不是看这个么)
PyQt4.QtGui import QX11Info
QX11Info.isCompositingManagerRunning()
直接把那个蓝条隐藏了
先来看方法1,主要是自定义mouseMoveEvent来达到的,完整代码在
这里
class MyTreeWidget(QtGui.QTreeWidget):
def mouseMoveEvent(self, e):
listsQModelIndex = self.selectedIndexes()
if listsQModelIndex:
dataQMimeData = self.model().mimeData(listsQModelIndex)
index = self.indexFromItem(self.currentItem(),0)
row = index.row()
drag_rect = self.visualItemRect(self.itemFromIndex(index))
header_height = self.header().sizeHint().height()
row_height = self.rowHeight(index)
# screenshot_y = row * row_height + header_height +2
screenshot_y = drag_rect.y() + row_height
pixmap = QtGui.QPixmap.grabWidget(self,0,screenshot_y,-1,row_height)
painter = QtGui.QPainter(pixmap)
painter.setCompositionMode(painter.CompositionMode_DestinationIn)
painter.fillRect(pixmap.rect(), QtGui.QColor(0, 0, 0, 127))
painter.end()
# make a QDrag
drag = QtGui.QDrag(self)
# put our MimeData
drag.setMimeData(dataQMimeData)
# set its Pixmap
drag.setPixmap(pixmap)
# shift the Pixmap so that it coincides with the cursor position
# drag.setHotSpot(e.pos())
drag.setHotSpot(QtCore.QPoint(e.pos().x(),e.pos().y()-screenshot_y+row_height))
# start the drag operation
# exec_ will return the accepted action from dropEvent
drag.exec_()
方法2 是通过reimplement startDrag, 但是不给QDrag Object设置pixmap达到的,完整代码在
这里
class MyTreeWidget(QtGui.QTreeWidget):
def startDrag (self, supportedActions):
listsQModelIndex = self.selectedIndexes()
if listsQModelIndex:
mimeData = QtCore.QMimeData()
dataQMimeData = self.model().mimeData(listsQModelIndex)
if not dataQMimeData:
return None
dragQDrag = QtGui.QDrag(self)
# dragQDrag.setPixmap(QtGui.QPixmap('test.jpg')) # <- For put your custom image here
dragQDrag.setMimeData(dataQMimeData)
defaultDropAction = QtCore.Qt.IgnoreAction
if ((supportedActions & QtCore.Qt.CopyAction) and (self.dragDropMode() != QtGui.QAbstractItemView.InternalMove)):
defaultDropAction = QtCore.Qt.CopyAction;
dragQDrag.exec_(supportedActions, defaultDropAction)
如下图所示,dragging indicator没有了
或者你也可以通过reimplement mouseMoveEvent来让dragging indicator没有掉,总之是要让QDrag Object没有pixmap,代码在
这里
class MyTreeWidget(QtGui.QTreeWidget):
def mouseMoveEvent(self, e):
mimeData = self.model().mimeData(self.selectedIndexes())
drag = QtGui.QDrag(self)
drag.setMimeData(mimeData)
# pixmap = QtGui.QPixmap('xxx.png')
# drag.setPixmap(pixmap)
drag.exec_(QtCore.Qt.MoveAction)
Dropping Indicator 自定义
此外,默认效果是拖拽过程中,未放手之前的话,如果鼠标位于其他item上,会画一个矩形框,如果位于两个item中间,会画一条横线,这在我要做的ui中有两个问题
我需要的是拖动整行,所以我希望矩形框是包围整行的
插入两个item之间的”判定”太弱,导致操作的时候很难把一个item拖动,并”插入”到另外的两个item之间
试了搜了下,用stylesheet似乎没法做到,即使我把行高设得很大,或是间隔设大,插入判定依然很弱,想插入的时候手一抖就变成相邻item的子item了
先试试看delegate能不能做到,似乎是不能,下面的例子中虽然可以让鼠标指向的行下方有一条横线,但是当你放手的时候,他该去到哪里还是会去哪里,delegate只是影响显示效果而已,代码在
这里
class MyDelegate(QtGui.QStyledItemDelegate):
def paint(self, painter, option, index):
QtGui.QStyledItemDelegate.paint(self, painter, option, index)
painter.save()
data = index.model().data(index, Qt.UserRole).toInt()
# if UserRole = 1 draw custom line
if data[1] and data[0] == 1:
line = QtCore.QLine(option.rect.topLeft(), option.rect.topRight())
painter.drawLine(line)
painter.restore()
class MyTreeWidget(QtGui.QTreeWidget):
def paintEventx(self, event):
painter = QtGui.QPainter(self.viewport())
self.drawTree(painter, event.region())
# in original implementation, it calls an inline function paintDropIndicator here
def dragMoveEvent(self, event):
pos = event.pos()
item = self.itemAt(pos)
# If hovered over an item during drag, set UserRole = 1
if item:
index = self.indexFromItem(item)
self.model().setData(index, 1, Qt.UserRole)
# reset UserRole to 0 for all other indices
# This only reset topLevel item UserRole data
# for i in range(self.model().rowCount()):
# _index = self.model().index(i, 0)
# if not item or index != _index:
# self.model().setData(_index, 0, Qt.UserRole)
iterator = QtGui.QTreeWidgetItemIterator(self)
while iterator.value():
item_iter = iterator.value()
if item_iter is not item:
_index = self.indexFromItem(item_iter, 0)
self.model().setData(_index, 0, Qt.UserRole)
iterator += 1
当然既然这是delegate,那我也可以想办法让他画一个框出来(当鼠标指向item的时候),但是我如何知道该怎么写呢,如果在网上搜不到例子的情况下,此时只好去看qt源码,按照
此法
clone到本机(当然你也可以
在线看
,但是lz觉得clone到本机后用jedit的搜索更方便)
首先在QTreeView.cpp的
paintEvent
里,你会看到他调用了paintDropIndicator method, 他位于
qabstractitemview_p.h
里,是个inline function,进一步搜索发现,在
qcommonstyle.cpp
里,他只是说如果rect的高度是0,就画一条横线,不然就画一个框
上图中,放手后,delegate画的横线还在那没有消失,这是因为放手之后,放手处的item上的UseRole的data依然是1没有变,我们需要把他再设回0,代码在
这里
class MyTreeWidget(QtGui.QTreeWidget):
def dropEvent(self,e):
pos = e.pos()
item = self.itemAt(pos)
if item:
index = self.indexFromItem(item)
self.model().setData(index, 0, Qt.UserRole)
QtGui.QTreeWidget.dropEvent(self,e)
self.expandAll()
为了在delegate里画出来框,显然你得知道鼠标当前在两个item之间,还是在item上,如果不知道怎么写,还是只能去源码里找,考虑到默认的效果是带框的,那显然应该去qabstractitemview.cpp的
dragMoveEvent
里找,根据这段代码可知,他是通过 position 这个method(位于
qabstractitemview_p.h
里)来得到鼠标指针当前位置,存在self.dropIndicatorPosition里,从而作出相应的是画线还是画框的举措的.
所以同理我可以把他搬过来,代码在
这里
class MyDelegate(QtGui.QStyledItemDelegate):
def paint(self, painter, option, index):
QtGui.QStyledItemDelegate.paint(self, painter, option, index)
painter.save()
data = index.model().data(index, Qt.UserRole).toInt()
option_rect = option.rect
# if UserRole = 1 draw custom line
if data[1]:
if data[0] == QtGui.QAbstractItemView.AboveItem:
line = QtCore.QLine(option_rect.topLeft(), option_rect.topRight())
painter.drawLine(line)
elif data[0] == QtGui.QAbstractItemView.BelowItem:
line = QtCore.QLine(option_rect.bottomLeft(), option_rect.bottomRight())
painter.drawLine(line)
elif data[0] == QtGui.QAbstractItemView.OnItem:
rect = QtCore.QRect(option_rect)
painter.drawRect(rect)
painter.restore()
通过在dragMoveEvent里把当前鼠标所在位置判断好,存在item的data里,然后在Delegate里再通过这个data来得知是该画线还是画框,判定是在position method里做出的,默认的margin是2,我给改成了10,你可以想像的到,margin*2 之后必须比行高小,不然你就没法得到 鼠标刚好在item之上(OnItem)的判定了,显然margin*2+x=行高,你至少得给x留有一定的大小吧
同时这个例子里也加上了鼠标悬停时候,下方的item的高亮阴影(通过stylesheet加的,不知为何在家里CentOS7上没加也有这效果,可能是更高版本的Qt自带效果吧,公司Qt比较老)
上图中的问题是,如图所示虽然我改变了放手时的判定,现在没以前那么灵敏了,(因为判定插入位置的要求放宽了,只要离间隙10px范围内都算是“插入”位置,之前是2px),但是当你放手的时候他依然把拖拽的item“放”到了放手处的item下面,成为了他的子item,这是因为delegate只是改变了显示效果(通过禁止默认的dropIndicator的绘制,自己画线或是框),实际你放手的话该怎么样还是会怎么样(因为你控制你放手之后发生了什么的操作在QTreeWidget的dropEvent里)
还有个问题是如果我想让框包围整行,在delegate里没法直接做到,因为delegate的参数的index只是当前列的index,(我只给第一列设了item.data),没法直接得到最右边一列是第几列,当然可以把treeWidget传给delegate,从而得到最右边一列的index,不过这样总感觉违背了model/view的设计目的
既然你都自己subclass了QTreeWidget了,那何不如 不要用delegate,直接reimplement TreeWidget的dropOn method,来使你放手时,被拖拽的item去到他应该去的地方,并reimplement paintDropIndicator,来画出你想要的drop indicator呢
代码在
这里
class MyTreeView(QtGui.QTreeView):
def __init__(self, parent=None):
super(MyTreeView, self).__init__(parent)
self.dropIndicatorRect = QtCore.QRect()
def paintEvent(self, event):
painter = QtGui.QPainter(self.viewport())
self.drawTree(painter, event.region())
# in original implementation, it calls an inline function paintDropIndicator here
self.paintDropIndicator(painter)
def paintDropIndicator(self, painter):
if self.state() == QtGui.QAbstractItemView.DraggingState:
opt = QtGui.QStyleOption()
opt.init(self)
opt.rect = self.dropIndicatorRect
rect = opt.rect
if rect.height() == 0:
pen = QtGui.QPen(QtCore.Qt.black, 2, QtCore.Qt.SolidLine)
painter.setPen(pen)
painter.drawLine(rect.topLeft(), rect.topRight())
else:
pen = QtGui.QPen(QtCore.Qt.black, 2, QtCore.Qt.SolidLine)
painter.setPen(pen)
painter.drawRect(rect)
在上图的效果中,既放宽了插入的判定,又画出了包围整行的框,而且放手的时候也是“所见即所得”的了(即当是横线的时候放手就是插入,是框的时候放手就是成为子item)
存在的问题
拖到自己身上放手,drop indicator不会消失
拖到其他列的上面,没放手之前drop indicator没显示出来
通过在position里加上判断是不是在item的中间,来判定是不是OnItem,因为在dragMoveEvent里用了item = self.itemAt(pos)来得到item,此时得到的总是第一列的index
如果在自己本身上放手,就不进行后续的删除插入操作了.
代码在
这里
def position(self, pos, rect, index):
r = QtGui.QAbstractItemView.OnViewport
# margin*2 must be smaller than row height, or the drop onItem rect won't show
margin = 10
if pos.y() - rect.top() < margin:
r = QtGui.QAbstractItemView.AboveItem
elif rect.bottom() - pos.y() < margin:
r = QtGui.QAbstractItemView.BelowItem
# this rect is always the first column rect
# elif rect.contains(pos, True):
elif pos.y() - rect.top() > margin and rect.bottom() - pos.y() > margin:
r = QtGui.QAbstractItemView.OnItem
return r
def dropEvent(self, event):
pos = event.pos()
item = self.itemAt(pos)
if item is self.currentItem():
QtGui.QTreeWidget.dropEvent(self, event)
event.accept()
return
未完待续 …