1

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.

enter image description here

How can this be achieved?

JacksonPro
  • 3,135
  • 2
  • 6
  • 29
  • The `menu-scrollable: 1;` property is undocumented, but only refers to the `SH_Menu_Scrollable` style hint which enables scrolling (instead of multiple columns) whenever the menu doesn't fit the *whole height* of the screen. – musicamante Jan 30 '21 at 10:36
  • I found that here: https://stackoverflow.com/a/33881047/12198502 . Also, can you post a link to the document? – JacksonPro Jan 30 '21 at 10:50
  • Link to what document? – musicamante Jan 30 '21 at 10:56
  • how do you use `SH_Menu_Scrollable`? – JacksonPro Jan 30 '21 at 11:03
  • `SH_Menu_Scrollable` is a style hint of QStyle that QMenu calls on init in order to be able to layout its items. You can see the stylesheet alias in the QStyleSheetStyle https://code.qt.io/cgit/qt/qtbase.git/tree/src/widgets/styles/qstylesheetstyle.cpp#n703 – musicamante Jan 30 '21 at 11:11

2 Answers2

3

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:

  1. what you see on your screen is never what the final user will see;
  2. 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;
  3. 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_())
musicamante
  • 41,230
  • 6
  • 33
  • 58
  • Hey thx for the answer. Since I am not an expert I might not be able to modify your code to fit my purpose. I have now corrected most of the problems in my code. Except that the active action changes automatically when there is a sub-menu( Use arrow keys for navigation). Do you mind telling me what's causing that? – JacksonPro Feb 02 '21 at 11:42
  • @musicmante can you give me a clue on how to achieve this: https://stackoverflow.com/q/66042420/12198502 ? – JacksonPro Feb 04 '21 at 15:31
  • @JacksonPro I've commented on that, and I must point out that you should not try to "hack your way" like you're often trying to do unless you have good understanding on how objects work (other than patiently studying the documentation, I suggest you to also browse the source code). About your previous question, it's due to the `setCurrentAction()`, which starts an internal timer if the action is a submenu, you could ignore that as I did in `timerEvent()`. – musicamante Feb 04 '21 at 16:04
  • I really appreciate your comment and feedback thank you once again for your time. – JacksonPro Feb 04 '21 at 16:19
  • Consider that there are very specific reasons for which my code is so extended, and it's not just "to make it cool". In order to correctly provide custom scrolling, careful analysis and studying of the source code is required, because QMenu is a complex widget that has to provide cross-platform compatibility and a reliable and predictable behavior, which must be consistent with *any other* menu in the OS. Its source code is almost 3k lines (without comments), and a big part of it is dedicated to displaying/layout and user interaction, all things you're trying to reimplement and cannot ignore. – musicamante Feb 04 '21 at 16:27
-1

For this, you will have to inherit the QMenu and override the wheelEvent.

Here is an example, which you may improve.

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

Explanation of the above code:

  • Store all the visible actions in a list say visible_lst.

  • scrolling down:

    1. increment the index when scrolling down
    2. Use self.visible_lst[0].setVisible(False) which will make that action invisible and then pop the list from the front.
    3. Append the next action to the visible_lst using self.actions()[self.index]
  • scrolling up:

    1. decrement the index when scrolling up
    2. use self.visible_lst[-1].setVisible(False) which will make the last item in the list hidden and pop the last element from the list
    3. Insert the previous element to the 0th index of visible_lst and make it visible using self.actions()[index].setVisible(True)

Output of the scrolling context menu code:

enter image description here

Readers, if you have suggestions or queries leave a comment.

JacksonPro
  • 3,135
  • 2
  • 6
  • 29
  • The concept is interesting, but I can see some issues related to the way you add and remove items (I even got an IndexError exception, but I've not been able to reproduce it yet). The arrows also create some problems: seeing arrows like those could lead the user to think that they clickable, but the action is clicked instead (which is not a good thing). There are other relatively minor issues: for instance, `addMenu()` accepts various argument signatures, and using the string (or icon+string) version should always return the action; removing an action shouldn't always subtract 1 from the index – musicamante Jan 30 '21 at 11:07
  • @musicamante thx for the info will update the answer after making corrections. The arrow is just an indicator as I didn't want to make it more complicated. – JacksonPro Jan 30 '21 at 11:17
  • @musicamante added an update. What's your thought on the updated code? – JacksonPro Jan 30 '21 at 12:30
  • I can still get inconsistencies, probably due to issues in recreating the visible list which is not always correctly sync'd with the actual contents and their geometries. If you add visual elements, you should ensure that they do not create confusion: if they cannot react to mouse events, then you should ignore them; then, there's another problem: depending on the font size, it's possible that the user moves the mouse on an element that is on the bottom but only partially visible, with the result that it will look like the *arrow* is highlighted (leading to the assumption that it's clickable). – musicamante Jan 31 '21 at 18:30
  • Note that there are other issues also: 1) item highlight is erratic and unpredictable when using the wheel; 2) you should call the base implementation of `keyPressEvent` for **all** keys that are not managed, otherwise you'll be missing the Escape key or any mnemonic shortcut; 3) the arrow are really too small to be used for mouse clicks; 4) there's almost no real benefit in reusing the same QPainter instance (while there are risks if you're not *really* careful in trying to do so), it's a pretty "light" and fast class to instantiate; – musicamante Feb 04 '21 at 16:16