Issue
I would like to have a scrollable context menu so that I can place many Actions in it. I saw an answer from another post that setting menu.setStyleSheet('QMenu{menu-scrollable: 1;}')
will enable scroll Bar but it doesn't seem to do the job.
Here is a demonstration of the context menu of the blender software.
How can this be achieved?
Solution
Dealing with menu customization (with any framework) is not an easy task, and from experience I can telly you that trying to use simple approaches such as toggling item visibility will most certainly result in unexpected behavior and issues in user experience.
Three aspects must be kept in mind:
- what you see on your screen is never what the final user will see;
- there's always a certain amount of scenarios that you didn't consider, mostly due to "lazy debugging" which leads you to always test just a handful of situations without being thorough enough;
- menus have been around for decades, and users are very accustomed to them, they know very well how they work and behave (even if unconsciously), and unusual behavior or visual hint can easily bring confusion and get them annoyed;
From your given answer, I can see at least the following important issues:
- there are serious problems in the way geometries and visibilities are dealt with, with the result that some items are visible even when they shouldn't;
- menu items can (and should) be programmatically hidden, resulting in unexpected behavior (especially since you might restore the visibility on a previously hidden item);
- items with very long text are not considered, and will be cropped;
- keyboard navigation is not supported, so the user can navigate to an invisible item;
- the arrows are misleading, since they overlap items and there's no hint about possible further scrolling (I know that this is also the way Qt normally behaves, but that's not the point);
- no "hover" scrolling is implemented, so a partially hidden item will result in a "highlighted arrow", which will lead the user to think that clicking will result in scrolling;
The solution, "unfortunately", is to correctly implement everything is needed, starting from painting and, obviously, user interaction.
The following is an almost complete implementation of a scrollable menu; the scrolling can be enabled by setting a maximum height or a maxItemCount
keyword argument that guesses the height based on a standard item; it is then activated by moving on the arrows (and/or clicking on them) as well as using keyboard arrows.
It's not perfect yet, there are probably some aspect I didn't consider (see the above "lazy debugging" note), but for what I can see it should work as expected.
And, yes, I know, it's really extended; but, as said, menus are not as simple as they look.
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_())
Answered By - musicamante
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.