如何在PyQt中拥有可滚动的上下文菜单

2024-03-28 16:38:05 发布

您现在位置:Python中文网/ 问答频道 /正文

我想有一个可滚动的上下文菜单,以便我可以在其中放置许多操作。我在另一篇文章中看到了一个答案,设置menu.setStyleSheet('QMenu{menu-scrollable: 1;}')将启用滚动条,但它似乎不起作用

下面是blender软件上下文菜单的演示

enter image description here

如何做到这一点


Tags: 答案软件文章菜单menublendersetstylesheetqmenu
2条回答

为此,您必须继承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

  • 向下滚动:

    1. 向下滚动时增加索引
    2. 使用self.visible_lst[0].setVisible(False)将使该操作不可见,然后从前面弹出列表
    3. 使用self.actions()[self.index]将下一个操作附加到visible_lst
  • 向上滚动:

    1. 向上滚动时减小索引
    2. 使用 self.visible_lst[-1].setVisible(False)将隐藏列表中的最后一项,并从列表中弹出最后一个元素
    3. 将上一个元素插入到visible_lst的第0个索引中,并使用self.actions()[index].setVisible(True)使其可见

滚动上下文菜单代码的输出:

enter image description here

读者,如果您有任何建议或疑问,请留言

处理菜单定制(使用任何框架)不是一项容易的任务,根据经验,我可以告诉您,尝试使用简单的方法,例如切换项目可见性,肯定会在用户体验中导致意外的行为和问题

必须牢记三个方面:

  1. 您在屏幕上看到的是最终用户将看到的从不<> LI>总是有一些场景,你没有考虑,主要是由于“懒惰调试”,这导致你总是只测试少量的情况而不够彻底;李>
  2. 菜单已经存在了几十年,用户对菜单非常习惯,他们非常清楚自己的工作和行为(即使是无意识的),异常行为或视觉提示很容易引起混乱和恼怒

从您给出的答案中,我至少可以看到以下重要问题:

  • 几何图形和可视性的处理方式存在严重问题,导致某些项目即使在不应该的情况下也可见
  • 菜单项可以(也应该)以编程方式隐藏,从而导致意外行为(特别是因为您可能会恢复以前隐藏项的可见性)
  • 不考虑文本过长的项目,将对其进行裁剪
  • 不支持键盘导航,因此用户可以导航到不可见的项目
  • 箭头是误导性的,因为它们重叠项目,并且没有关于可能进一步滚动的提示(我知道这也是Qt通常的行为方式,但这不是重点)
  • 未实现“悬停”滚动,因此部分隐藏的项目将导致“突出显示的箭头”,这将导致用户认为单击将导致滚动

不幸的是,解决方案是正确地实现所需的一切,从绘画开始,显然,从用户交互开始

下面是一个几乎完整的可滚动菜单的实现;可以通过设置最大高度或maxItemCount关键字参数来启用滚动,该参数根据标准项猜测高度;然后通过移动箭头(和/或单击箭头)以及使用键盘箭头来激活它。
它还不完善,可能还有一些方面我没有考虑(见上面的“懒惰调试”注释),但是对于我所看到的,它应该按照预期工作。p>

而且,是的,我知道,它真的扩展了;但是,如上所述,菜单并不像看上去那么简单

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_())

相关问题 更多 >