[英]How to have a scrollable context menu in PyQt
处理菜单自定义(使用任何框架)都不是一件容易的事,根据我的经验,我可以告诉您,尝试使用简单的方法(例如切换项目可见性)肯定会导致意外行为和用户体验问题。
必须牢记三个方面:
从您给出的答案中,我至少可以看出以下重要问题:
“不幸的是”,解决方案是正确实现所需的一切,从绘画开始,当然还有用户交互。
下面是一个几乎完整的可滚动菜单的实现; 可以通过设置最大高度或基于标准项目猜测高度的maxItemCount
关键字参数来启用滚动; 然后通过移动箭头(和/或单击它们)以及使用键盘箭头来激活它。
它还不完美,可能有些方面我没有考虑(参见上面的“懒惰调试”注释),但就我所见,它应该按预期工作。
而且,是的,我知道,它真的扩展了; 但是,如前所述,菜单并不像看起来那么简单。
class ScrollableMenu(QtWidgets.QMenu):
deltaY = 0
dirty = True
ignoreAutoScroll = False
def __init__(self, *args, **kwargs):
maxItemCount = kwargs.pop('maxItemCount', 0)
super().__init__(*args, **kwargs)
self._maximumHeight = self.maximumHeight()
self._actionRects = []
self.scrollTimer = QtCore.QTimer(self, interval=50, singleShot=True, timeout=self.checkScroll)
self.scrollTimer.setProperty('defaultInterval', 50)
self.delayTimer = QtCore.QTimer(self, interval=100, singleShot=True)
self.setMaxItemCount(maxItemCount)
@property
def actionRects(self):
if self.dirty or not self._actionRects:
self._actionRects.clear()
offset = self.offset()
for action in self.actions():
geo = super().actionGeometry(action)
if offset:
geo.moveTop(geo.y() - offset)
self._actionRects.append(geo)
self.dirty = False
return self._actionRects
def iterActionRects(self):
for action, rect in zip(self.actions(), self.actionRects):
yield action, rect
def setMaxItemCount(self, count):
style = self.style()
opt = QtWidgets.QStyleOptionMenuItem()
opt.initFrom(self)
a = QtWidgets.QAction('fake action', self)
self.initStyleOption(opt, a)
size = QtCore.QSize()
fm = self.fontMetrics()
qfm = opt.fontMetrics
size.setWidth(fm.boundingRect(QtCore.QRect(), QtCore.Qt.TextSingleLine, a.text()).width())
size.setHeight(max(fm.height(), qfm.height()))
self.defaultItemHeight = style.sizeFromContents(style.CT_MenuItem, opt, size, self).height()
if not count:
self.setMaximumHeight(self._maximumHeight)
else:
fw = style.pixelMetric(style.PM_MenuPanelWidth, None, self)
vmargin = style.pixelMetric(style.PM_MenuHMargin, opt, self)
scrollHeight = self.scrollHeight(style)
self.setMaximumHeight(
self.defaultItemHeight * count + (fw + vmargin + scrollHeight) * 2)
self.dirty = True
def scrollHeight(self, style):
return style.pixelMetric(style.PM_MenuScrollerHeight, None, self) * 2
def isScrollable(self):
return self.height() < super().sizeHint().height()
def checkScroll(self):
pos = self.mapFromGlobal(QtGui.QCursor.pos())
delta = max(2, int(self.defaultItemHeight * .25))
if pos in self.scrollUpRect:
delta *= -1
elif pos not in self.scrollDownRect:
return
if self.scrollBy(delta):
self.scrollTimer.start(self.scrollTimer.property('defaultInterval'))
def offset(self):
if self.isScrollable():
return self.deltaY - self.scrollHeight(self.style())
return 0
def translatedActionGeometry(self, action):
return self.actionRects[self.actions().index(action)]
def ensureVisible(self, action):
style = self.style()
fw = style.pixelMetric(style.PM_MenuPanelWidth, None, self)
hmargin = style.pixelMetric(style.PM_MenuHMargin, None, self)
vmargin = style.pixelMetric(style.PM_MenuVMargin, None, self)
scrollHeight = self.scrollHeight(style)
extent = fw + hmargin + vmargin + scrollHeight
r = self.rect().adjusted(0, extent, 0, -extent)
geo = self.translatedActionGeometry(action)
if geo.top() < r.top():
self.scrollBy(-(r.top() - geo.top()))
elif geo.bottom() > r.bottom():
self.scrollBy(geo.bottom() - r.bottom())
def scrollBy(self, step):
if step < 0:
newDelta = max(0, self.deltaY + step)
if newDelta == self.deltaY:
return False
elif step > 0:
newDelta = self.deltaY + step
style = self.style()
scrollHeight = self.scrollHeight(style)
bottom = self.height() - scrollHeight
for lastAction in reversed(self.actions()):
if lastAction.isVisible():
break
lastBottom = self.actionGeometry(lastAction).bottom() - newDelta + scrollHeight
if lastBottom < bottom:
newDelta -= bottom - lastBottom
if newDelta == self.deltaY:
return False
self.deltaY = newDelta
self.dirty = True
self.update()
return True
def actionAt(self, pos):
for action, rect in self.iterActionRects():
if pos in rect:
return action
# class methods reimplementation
def sizeHint(self):
hint = super().sizeHint()
if hint.height() > self.maximumHeight():
hint.setHeight(self.maximumHeight())
return hint
def eventFilter(self, source, event):
if event.type() == event.Show:
if self.isScrollable() and self.deltaY:
action = source.menuAction()
self.ensureVisible(action)
rect = self.translatedActionGeometry(action)
delta = rect.topLeft() - self.actionGeometry(action).topLeft()
source.move(source.pos() + delta)
return False
return super().eventFilter(source, event)
def event(self, event):
if not self.isScrollable():
return super().event(event)
if event.type() == event.KeyPress and event.key() in (QtCore.Qt.Key_Up, QtCore.Qt.Key_Down):
res = super().event(event)
action = self.activeAction()
if action:
self.ensureVisible(action)
self.update()
return res
elif event.type() in (event.MouseButtonPress, event.MouseButtonDblClick):
pos = event.pos()
if pos in self.scrollUpRect or pos in self.scrollDownRect:
if event.button() == QtCore.Qt.LeftButton:
step = max(2, int(self.defaultItemHeight * .25))
if pos in self.scrollUpRect:
step *= -1
self.scrollBy(step)
self.scrollTimer.start(200)
self.ignoreAutoScroll = True
return True
elif event.type() == event.MouseButtonRelease:
pos = event.pos()
self.scrollTimer.stop()
if not (pos in self.scrollUpRect or pos in self.scrollDownRect):
action = self.actionAt(event.pos())
if action:
action.trigger()
self.close()
return True
return super().event(event)
def timerEvent(self, event):
if not self.isScrollable():
# ignore internal timer event for reopening popups
super().timerEvent(event)
def mouseMoveEvent(self, event):
if not self.isScrollable():
super().mouseMoveEvent(event)
return
pos = event.pos()
if pos.y() < self.scrollUpRect.bottom() or pos.y() > self.scrollDownRect.top():
if not self.ignoreAutoScroll and not self.scrollTimer.isActive():
self.scrollTimer.start(200)
return
self.ignoreAutoScroll = False
oldAction = self.activeAction()
if not pos in self.rect():
action = None
else:
y = event.y()
for action, rect in self.iterActionRects():
if rect.y() <= y <= rect.y() + rect.height():
break
else:
action = None
self.setActiveAction(action)
if action and not action.isSeparator():
def ensureVisible():
self.delayTimer.timeout.disconnect()
self.ensureVisible(action)
try:
self.delayTimer.disconnect()
except:
pass
self.delayTimer.timeout.connect(ensureVisible)
self.delayTimer.start(150)
elif oldAction and oldAction.menu() and oldAction.menu().isVisible():
def closeMenu():
self.delayTimer.timeout.disconnect()
oldAction.menu().hide()
self.delayTimer.timeout.connect(closeMenu)
self.delayTimer.start(50)
self.update()
def wheelEvent(self, event):
if not self.isScrollable():
return
self.delayTimer.stop()
if event.angleDelta().y() < 0:
self.scrollBy(self.defaultItemHeight)
else:
self.scrollBy(-self.defaultItemHeight)
def showEvent(self, event):
if self.isScrollable():
self.deltaY = 0
self.dirty = True
for action in self.actions():
if action.menu():
action.menu().installEventFilter(self)
self.ignoreAutoScroll = False
super().showEvent(event)
def hideEvent(self, event):
for action in self.actions():
if action.menu():
action.menu().removeEventFilter(self)
super().hideEvent(event)
def resizeEvent(self, event):
super().resizeEvent(event)
style = self.style()
l, t, r, b = self.getContentsMargins()
fw = style.pixelMetric(style.PM_MenuPanelWidth, None, self)
hmargin = style.pixelMetric(style.PM_MenuHMargin, None, self)
vmargin = style.pixelMetric(style.PM_MenuVMargin, None, self)
leftMargin = fw + hmargin + l
topMargin = fw + vmargin + t
bottomMargin = fw + vmargin + b
contentWidth = self.width() - (fw + hmargin) * 2 - l - r
scrollHeight = self.scrollHeight(style)
self.scrollUpRect = QtCore.QRect(leftMargin, topMargin, contentWidth, scrollHeight)
self.scrollDownRect = QtCore.QRect(leftMargin, self.height() - scrollHeight - bottomMargin,
contentWidth, scrollHeight)
def paintEvent(self, event):
if not self.isScrollable():
super().paintEvent(event)
return
style = self.style()
qp = QtGui.QPainter(self)
rect = self.rect()
emptyArea = QtGui.QRegion(rect)
menuOpt = QtWidgets.QStyleOptionMenuItem()
menuOpt.initFrom(self)
menuOpt.state = style.State_None
menuOpt.maxIconWidth = 0
menuOpt.tabWidth = 0
style.drawPrimitive(style.PE_PanelMenu, menuOpt, qp, self)
fw = style.pixelMetric(style.PM_MenuPanelWidth, None, self)
topEdge = self.scrollUpRect.bottom()
bottomEdge = self.scrollDownRect.top()
offset = self.offset()
qp.save()
qp.translate(0, -offset)
# offset translation is required in order to allow correct fade animations
for action, actionRect in self.iterActionRects():
actionRect = self.translatedActionGeometry(action)
if actionRect.bottom() < topEdge:
continue
if actionRect.top() > bottomEdge:
continue
visible = QtCore.QRect(actionRect)
if actionRect.bottom() > bottomEdge:
visible.setBottom(bottomEdge)
elif actionRect.top() < topEdge:
visible.setTop(topEdge)
visible = QtGui.QRegion(visible.translated(0, offset))
qp.setClipRegion(visible)
emptyArea -= visible.translated(0, -offset)
opt = QtWidgets.QStyleOptionMenuItem()
self.initStyleOption(opt, action)
opt.rect = actionRect.translated(0, offset)
style.drawControl(style.CE_MenuItem, opt, qp, self)
qp.restore()
cursor = self.mapFromGlobal(QtGui.QCursor.pos())
upData = (
False, self.deltaY > 0, self.scrollUpRect
)
downData = (
True, actionRect.bottom() - 2 > bottomEdge, self.scrollDownRect
)
for isDown, enabled, scrollRect in upData, downData:
qp.setClipRect(scrollRect)
scrollOpt = QtWidgets.QStyleOptionMenuItem()
scrollOpt.initFrom(self)
scrollOpt.state = style.State_None
scrollOpt.checkType = scrollOpt.NotCheckable
scrollOpt.maxIconWidth = scrollOpt.tabWidth = 0
scrollOpt.rect = scrollRect
scrollOpt.menuItemType = scrollOpt.Scroller
if enabled:
if cursor in scrollRect:
frame = QtWidgets.QStyleOptionMenuItem()
frame.initFrom(self)
frame.rect = scrollRect
frame.state |= style.State_Selected | style.State_Enabled
style.drawControl(style.CE_MenuItem, frame, qp, self)
scrollOpt.state |= style.State_Enabled
scrollOpt.palette.setCurrentColorGroup(QtGui.QPalette.Active)
else:
scrollOpt.palette.setCurrentColorGroup(QtGui.QPalette.Disabled)
if isDown:
scrollOpt.state |= style.State_DownArrow
style.drawControl(style.CE_MenuScroller, scrollOpt, qp, self)
if fw:
borderReg = QtGui.QRegion()
borderReg |= QtGui.QRegion(QtCore.QRect(0, 0, fw, self.height()))
borderReg |= QtGui.QRegion(QtCore.QRect(self.width() - fw, 0, fw, self.height()))
borderReg |= QtGui.QRegion(QtCore.QRect(0, 0, self.width(), fw))
borderReg |= QtGui.QRegion(QtCore.QRect(0, self.height() - fw, self.width(), fw))
qp.setClipRegion(borderReg)
emptyArea -= borderReg
frame = QtWidgets.QStyleOptionFrame()
frame.rect = rect
frame.palette = self.palette()
frame.state = QtWidgets.QStyle.State_None
frame.lineWidth = style.pixelMetric(style.PM_MenuPanelWidth)
frame.midLineWidth = 0
style.drawPrimitive(style.PE_FrameMenu, frame, qp, self)
qp.setClipRegion(emptyArea)
menuOpt.state = style.State_None
menuOpt.menuItemType = menuOpt.EmptyArea
menuOpt.checkType = menuOpt.NotCheckable
menuOpt.rect = menuOpt.menuRect = rect
style.drawControl(style.CE_MenuEmptyArea, menuOpt, qp, self)
class Test(QtWidgets.QWidget):
def __init__(self):
super().__init__()
self.menu = ScrollableMenu(maxItemCount=5)
self.menu.addAction('test action')
for i in range(10):
self.menu.addAction('Action {}'.format(i + 1))
if i & 1:
self.menu.addSeparator()
subMenu = self.menu.addMenu('very long sub menu')
subMenu.addAction('goodbye')
self.menu.triggered.connect(self.menuTriggered)
def menuTriggered(self, action):
print(action.text())
def contextMenuEvent(self, event):
self.menu.exec_(event.globalPos())
if __name__ == '__main__':
import sys
app = QtWidgets.QApplication(sys.argv)
test = Test()
test.show()
sys.exit(app.exec_())
为此,您必须继承QMenu
并覆盖wheelEvent
。
这是一个示例,您可以对其进行改进。
import sys
from PyQt5 import QtWidgets, QtCore, QtGui
class CustomMenu(QtWidgets.QMenu):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setStyleSheet('QMenu{background: gray;} QMenu::item:selected {background: rgb(58, 145, 232);}')
self.setFixedHeight(100)
self.visible_lst = []
self.index = -1
self.visibleCount = None
self.maxHeightOfAction = 0
# -------- Remove Below code if you don't want the arrow ---------------#
self.topArrow = None
self.bottomArrow = None
self.painter = QtGui.QPainter()
# ---------------------------------------- #
def actionEvent(self, event):
if event.type() == QtCore.QEvent.ActionAdded:
if self.maxHeightOfAction < self.actionGeometry(self.actions()[-1]).height():
self.maxHeightOfAction = self.actionGeometry(self.actions()[-1]).height()
self.actions()[-1].setVisible(False)
self.updateActions()
if event.type() == QtCore.QEvent.ActionRemoved:
super(CustomMenu, self).actionEvent(event)
if self.index == len(self.actions()):
self.index -= 1
if event.action() in self.visible_lst:
self.visible_lst.remove(event.action())
self.removed()
super(CustomMenu, self).actionEvent(event)
def updateActions(self):
if self.actions():
if self.findVisibleCount() > len(self.actions()) and self.index == -1:
self.visible_lst = self.actions()
self.updateVisible()
elif self.findVisibleCount() < len(self.actions()) and self.index == -1:
self.index += 1
self.visible_lst = self.actions()[0: self.findVisibleCount()]
self.updateVisible()
self.setActiveAction(self.visible_lst[0])
def removed(self):
if len(self.actions()) > self.findVisibleCount():
if self.index < len(self.actions())-2:
index = self.findIndex(self.visible_lst, self.activeAction())
self.visible_lst.append(self.actions()[self.index + (index-self.findVisibleCount())-1])
elif self.index == len(self.actions())-1:
self.visible_lst.insert(0, self.actions()[-self.findVisibleCount()-1])
self.updateVisible()
def findVisibleCount(self): # finds how many QActions will be visible
visibleWidgets = 0
if self.actions():
try:
visibleWidgets = self.height()//self.maxHeightOfAction
except ZeroDivisionError:
pass
return visibleWidgets
def mousePressEvent(self, event) -> None:
if self.topArrow.containsPoint(event.pos(), QtCore.Qt.OddEvenFill) and self.index>0:
self.scrollUp()
elif self.bottomArrow.containsPoint(event.pos(), QtCore.Qt.OddEvenFill) and self.index < len(self.actions()) -1:
self.scrollDown()
else:
super(CustomMenu, self).mousePressEvent(event)
def keyPressEvent(self, event):
if self.actions():
if self.activeAction() is None:
self.setActiveAction(self.actions()[self.index])
if event.key() == QtCore.Qt.Key_Up:
self.scrollUp()
elif event.key() == QtCore.Qt.Key_Down:
self.scrollDown()
elif event.key() == QtCore.Qt.Key_Return:
super(CustomMenu, self).keyPressEvent(event)
def wheelEvent(self, event):
if self.actions():
if self.activeAction() is None:
self.setActiveAction(self.actions()[self.index])
delta = event.angleDelta().y()
if delta < 0: # scroll down
self.scrollDown()
elif delta > 0: # scroll up
self.scrollUp()
def scrollDown(self):
if self.index < len(self.actions())-1:
self.index = self.findIndex(self.actions(), self.activeAction()) + 1
try:
self.setActiveAction(self.actions()[self.index])
if self.activeAction() not in self.visible_lst and len(self.actions()) > self.findVisibleCount():
self.visible_lst[0].setVisible(False)
self.visible_lst.pop(0)
self.visible_lst.append(self.actions()[self.index])
self.visible_lst[-1].setVisible(True)
except IndexError:
pass
def scrollUp(self):
if self.findIndex(self.actions(), self.activeAction()) > 0:
self.index = self.findIndex(self.actions(), self.activeAction()) - 1
try:
self.setActiveAction(self.actions()[self.index])
if self.activeAction() not in self.visible_lst and len(self.actions()) > self.findVisibleCount():
self.visible_lst[-1].setVisible(False)
self.visible_lst.pop()
self.visible_lst.insert(0, self.actions()[self.index])
self.visible_lst[0].setVisible(True)
except IndexError:
pass
def updateVisible(self):
for item in self.visible_lst:
if not item.isVisible():
item.setVisible(True)
def findIndex(self, lst, element):
for index, item in enumerate(lst):
if item == element:
return index
return -1
def paintEvent(self, event): # remove this if you don't want the arrow
super(CustomMenu, self).paintEvent(event)
height = int(self.height())
width = self.width()//2
topPoints = [QtCore.QPoint(width-5, 7), QtCore.QPoint(width, 2), QtCore.QPoint(width+5, 7)]
bottomPoints = [QtCore.QPoint(width-5, height-7), QtCore.QPoint(width, height-2), QtCore.QPoint(width+5, height-7)]
self.topArrow = QtGui.QPolygon(topPoints)
self.bottomArrow = QtGui.QPolygon(bottomPoints)
self.painter.begin(self)
self.painter.setBrush(QtGui.QBrush(QtCore.Qt.white))
self.painter.setPen(QtCore.Qt.white)
if len(self.actions()) > self.findVisibleCount():
if self.index>0:
self.painter.drawPolygon(self.topArrow)
if self.index < len(self.actions()) -1:
self.painter.drawPolygon(self.bottomArrow)
self.painter.end()
class ExampleWindow(QtWidgets.QWidget):
def contextMenuEvent(self, event) -> None:
menu = CustomMenu()
menu.addAction('Hello0')
menu.addAction('Hello1')
menu.addAction('Hello2')
menu.addAction('Hello3')
menu.addAction('Hello4')
menu.addAction('Hello5')
menu.addAction('Hello6')
menu.addAction('Hello7')
menu.addAction('Hello8')
menu.exec_(QtGui.QCursor.pos())
def main():
app = QtWidgets.QApplication(sys.argv)
window = ExampleWindow()
window.setWindowTitle('PyQt5 App')
window.show()
app.exec_()
if __name__ == '__main__':
main()
上面代码的解释:
将所有可见操作存储在列表中,比如visible_lst
。
向下滚动:
self.visible_lst[0].setVisible(False)
这将使该操作不可见,然后从前面弹出列表。visible_lst
使用self.actions()[self.index]
对 visible_lst 的下一个操作向上滚动:
self.visible_lst[-1].setVisible(False)
这将使列表中的最后一项隐藏并从列表中弹出最后一个元素visible_lst
的第 0 个索引,并使用self.actions()[index].setVisible(True)
使其可见Output 的滚动上下文菜单代码:
读者,如果您有任何建议或疑问,请发表评论。
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.