Scrolling Scrollers via the Mouse-wheel

It can be quite convenient to move around scrollable areas via the mouse-wheel. And indeed, this can be achieved via binding a given widget’s wheel-events to a custom callback.

However, as items are added to a given scrollable area, DirectGUI starts to impede this approach: each widget may block events from being passed on, meaning that one may end up binding the wheel-events of nearly each and every widget in order to keep things working.

And with a simple-enough menu, this may not be a problem. With a more-complex one, it may become tiresome, and perhaps even mistake-prone.

Hence the following method (which was developed with aid from the forum).

In short, it uses a ButtonThrower to detect wheel-events, upon which it examines the scrolled-frames nominated by the current menu, determines whether the mouse-pointer is within one, and if so, scrolls it.

Code:

The code below is comes from from a project of mine, and contains some elements specific to that project. I’ve adapted some elements, omitted others, and just left still more. As a result, some translation to your own projects might be called for.

For one thing, this comes from a static class, hence the use of class-references and the “@staticmethod” keyword. However, this isn’t inherent to the feature being demonstrated, so feel free to implement it elsehow, such as in an instanced class.

Finally, as I said, I’ve omitted a few things specific to my project. These call for filling in before the code will work properly, if I’m not much mistaken.

Initialisation:

        MenuController.navigationEventObject = DirectObject()

        MenuController.universalWheelThrower = base.buttonThrowers[0].node()
        # But consider using a custom button-thrower, with a prefix applied, if 
        # doing the above might result in interference with or from other
        # similar code. In my case, this seemed to be called for, indeed.
        MenuController.navigationEventObject.accept("wheel_up-up", MenuController.handleMouseWheel, extraArgs = [-1])
        MenuController.navigationEventObject.accept("wheel_down-up", MenuController.handleMouseWheel, extraArgs = [1])

Detection and response:

    @staticmethod
    def handleMouseWheel(dir):
        menu = # Get your menu, however you've implemented that
        if menu is not None:

            # If the user is scrolling a scroll-bar, don't try
            # to scroll the scrolled-frame too. However, if you
            # don't have any scroll-bars, or don't scroll them via
            # mouse-wheel, then feel free to omit this.
            region = base.mouseWatcherNode.getOverRegion()
            if region is not None:
                widget = base.aspect2d.find("**/*{0}".format(region.name))
                if isinstance(widget.node(), PGSliderBar) or isinstance(widget.getParent().node(), PGSliderBar):
                    return

            # Get the mouse-position
            if base.mouseWatcherNode.hasMouse():
                mousePos = base.mouseWatcherNode.getMouse()
            else:
                mousePos = # Some stored value, as you deem fit

            # This is expected to be a list (or tuple) of
            # DirectScrolledFrame instances.
            scrollers = menu.currentScrolledFrameList

            foundScroller = None

            # Determine whether any of the scrolled-frames are
            # under the mouse-pointer
            for scroller in scrollers:
                if scroller is not None:
                    # I've found that the DirectGUI "getBounds"
                    # method doesn't always seem to return
                    # useful data. Perhaps there's a better method
                    # to call, but for myself I've implemented my
                    # own. I leave it to the developer to choose
                    # how they go about this.
                    bounds = # Get the bounds of the scroller

                    if mousePos.x > bounds[0] and mousePos.x < bounds[1] and \
                        mousePos.y > bounds[2] and mousePos.y < bounds[3]:
                        foundScroller = scroller
                        break

            if foundScroller is not None:
                # This is slightly hacky: if there's a horizontal scroll-bar, 
                # presume that we want to scroll that way. Otherwise,
                # scroll vertically.
                if not foundScroller.horizontalScroll.isHidden():
                    MenuController.handleMouseScroll(foundScroller.horizontalScroll, dir, None)
                else:
                    MenuController.handleMouseScroll(foundScroller.verticalScroll, dir, None)

The actual scrolling:

    @staticmethod
    def handleMouseScroll(obj, dir, data):
        if isinstance(obj, DirectSlider) or isinstance(obj, DirectScrollBar):
            obj.setValue(obj.getValue() + dir*obj["pageSize"])
1 Like

I think a minimum reproducible example of this would be super useful.

Hmm… I’ll consider it, I intend. I do have various other projects on my plate at the moment, however.

That said, I’ll note that the rest of the code would seem to depend on how one decides to implement one’s menus. I am hesitant to seem to recommend any specific approach here.

I understand that feeling well

Ya that makes a lot of sense and thus why you showed the pieces without putting them together. But I think it’s quite common to start from a working example, and then transform it into something custom, especially for beginners.

In the meantime, for anyone who gets here from google (as I did), here is an out-of-box example of a scrollbar that moves in response to the mouse wheel. It is a lot less robust than what @Thaumaturge has implemented, but it may help you get started.

from panda3d.core import *
from direct.gui.DirectGui import *
from direct.showbase.ShowBase import ShowBase

class Demo(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        self.frame = DirectScrolledFrame(
            # This is the size of the 'frame'. The frame is the visible region of the
            # canvas. The order is left, right, bottom, top
            canvasSize = (-1, 1, -2, 1),
            # This is the size of the 'canvas'. The canvas is the whole scrollable area.
            # The order is left, right, bottom, top
            frameSize = (-1, 1, -1, 1),
            # This example is for vertical mouse scrolling, so I'm setting the
            # horizontal scrollbar to be invisible
            horizontalScroll_frameSize=(0, 0, 0, 0),
            parent=aspect2d,
        )

        # This is an example of content that would live _inside_ the canvas. Any content
        # that you want to scroll through should be parented to self.frame.getCanvas(),
        # as is done below:
        title = DirectLabel(
            text = "I scroll",
            scale = 0.1,
            pos = (0, 0, 0),
            parent = self.frame.getCanvas(),
        )

        # This is an example of content that lives _outside_ the canvas (since it is not
        # parented to self.frame.getCanvas()):
        title = DirectLabel(
            text = "I don't",
            scale = 0.1,
            pos = (0, 0, -0.4),
            parent = self.frame,
        )

        # 'wheel_up' and 'wheel_down' are the names of mouse scroll events that you want
        # to listen for. https://docs.panda3d.org/1.10/python/programming/hardware-support/mouse-support
        self.accept('wheel_up', self.scroll_menu, [False])
        self.accept('wheel_down', self.scroll_menu, [True])


    def scroll_menu(self, down):
        """Called whenever a mouse wheel (or trackpad) event is detected"""
        # Grab the scroll bar
        scroll_bar = self.frame.verticalScroll
        # Determine whether to scroll up or down
        modifier = -1 if down else 1
        # Scroll baby scroll
        scroll_bar.setValue(scroll_bar.getValue() - modifier*scroll_bar['pageSize'])


demo = Demo()
demo.run()
1 Like