wx.lib.ogl.ShapeCanvas does not scroll with mouse wheel

I am on

  • wxPython 4.2.3,
  • macbook pro m1,
  • running Sequoia 15.5 and
  • Python version 3.12.9;

I ran into this problem where I used to use a wx.ScrolledWindow as my App top window. I changed to a ShapeCanvas window but it does not scroll when using my Mac mouse middle button scroll (aka virtual wheel)

The ShapeCanvas is a subclass of ScrolledWindow. The following sample program demonstrates the problem

from wx import App
from wx import DEFAULT_FRAME_STYLE
from wx import FRAME_FLOAT_ON_PARENT
from wx import Frame
from wx import ScrolledWindow
from wx import Size

from wx.lib.ogl import Diagram
from wx.lib.ogl import OGLInitialize
from wx.lib.ogl import ShapeCanvas

NOMINAL_SIZE: Size = Size(400, 475)

DEFAULT_WIDTH: int   = 3000
A4_FACTOR:     float = 1.41

PIXELS_PER_UNIT_X: int = 20
PIXELS_PER_UNIT_Y: int = 20


class NoScrollShapeCanvas(App):
    def __init__(self):
        super().__init__(redirect=False)    # This calls OnInit()

        self._wxFrame = None
        self._shapeCanvas = None

    def OnInit(self):
        OGLInitialize()
        self._wxFrame = Frame(parent=None, title='Bogus', size=NOMINAL_SIZE, style=DEFAULT_FRAME_STYLE | FRAME_FLOAT_ON_PARENT)

        #
        # When shapedCanvas is a Scrolled Window, the middle button (wheel) scrolls the window
        #
        self._shapeCanvas = ScrolledWindow(self._wxFrame, size=NOMINAL_SIZE)
        #
        # Comment the above
        # Remove the comment out for the following 2 lines and the 'ScrolledWindow'
        # does not scroll on a middle button (even though it is a 'ScrolledWindow')
        #
        # self._shapeCanvas = ShapeCanvas(self._wxFrame, size=NOMINAL_SIZE)
        # self._shapeCanvas.SetDiagram(diag=Diagram())    # incestuous behavior here
        self._setupScrolling(self._shapeCanvas)

        self.SetTopWindow(self._wxFrame)

        self._wxFrame.SetAutoLayout(True)
        self._wxFrame.Show(True)

        self._wxFrame.SetMinSize(NOMINAL_SIZE)
        return True

    def _setupScrolling(self, shapeCanvas):
        self.maxWidth:  int  = DEFAULT_WIDTH
        self.maxHeight: int = int(self.maxWidth / A4_FACTOR)  # 1.41 is for A4 support

        nbrUnitsX: int = int(self.maxWidth / PIXELS_PER_UNIT_X)
        nbrUnitsY: int = int(self.maxHeight / PIXELS_PER_UNIT_Y)
        initPosX:  int = 0
        initPosY:  int = 0
        shapeCanvas.SetScrollbars(PIXELS_PER_UNIT_X, PIXELS_PER_UNIT_Y, nbrUnitsX, nbrUnitsY, initPosX, initPosY, False)


if __name__ == '__main__':

    testApp: NoScrollShapeCanvas = NoScrollShapeCanvas()

    testApp.MainLoop()

Looking in canvas.py at the source code for the ShapeCanvas class, I see that it binds EVT_MOUSE_EVENTS to its OnMouseEvent() method.

This causes OnMouseEvent() to receive all mouse events. However, that handler doesn’t call evt.GetWheelRotation() so presumably all mouse wheel events are ignored.

Perhaps you could create a subclass of ShapeCanvas and override its OnMouseEvent() method to do the scrolling when evt.WheelRotation != 0, otherwise it should call the base class method.

However, I don’t know if that would interfere with the ShapeCanvas’ normal behaviour.

Below is some modified code which I used to investigate the question. It needs some more code to actually do the scrolling.

import wx
from wx import App
from wx import DEFAULT_FRAME_STYLE
from wx import FRAME_FLOAT_ON_PARENT
from wx import Frame
from wx import ScrolledWindow
from wx import Size

from wx.lib.ogl import Diagram
from wx.lib.ogl import OGLInitialize
from wx.lib.ogl import ShapeCanvas

NOMINAL_SIZE: Size = Size(400, 475)

DEFAULT_WIDTH: int   = 3000
A4_FACTOR:     float = 1.41

PIXELS_PER_UNIT_X: int = 20
PIXELS_PER_UNIT_Y: int = 20


class MyShapeCanvas(ShapeCanvas):
    def OnMouseEvent(self, evt):
        """The mouse event handler."""

        if evt.WheelRotation == 0:
            ShapeCanvas.OnMouseEvent(self, evt)

        else:
            # Get current scroll position
            scroll_pos_x = self.GetScrollPos(wx.HORIZONTAL)
            scroll_pos_y = self.GetScrollPos(wx.VERTICAL)

            # On MaxBook Pro, WheelAxis is 0 for
            # vertical axis and 1 for horizontal axis
            print(scroll_pos_x, scroll_pos_y, evt.WheelAxis, evt.WheelRotation)

            # Calculate x, y - the position to scroll to *in scroll units*
            # and pass to self.Scroll(x, y) ?
            pass


class NoScrollShapeCanvas(App):
    def __init__(self):
        super().__init__(redirect=False)    # This calls OnInit()

        self._wxFrame = None
        self._shapeCanvas = None

    def OnInit(self):
        OGLInitialize()
        self._wxFrame = Frame(parent=None, title='Bogus', size=NOMINAL_SIZE, style=DEFAULT_FRAME_STYLE | FRAME_FLOAT_ON_PARENT)

        #
        # When shapedCanvas is a Scrolled Window, the middle button (wheel) scrolls the window
        #
        # self._shapeCanvas = ScrolledWindow(self._wxFrame, size=NOMINAL_SIZE)
        #
        # Comment the above
        # Remove the comment out for the following 2 lines and the 'ScrolledWindow'
        # does not scroll on a middle button (even though it is a 'ScrolledWindow')
        #
        self._shapeCanvas = MyShapeCanvas(self._wxFrame, size=NOMINAL_SIZE)
        self._shapeCanvas.SetDiagram(diag=Diagram())    # incestuous behavior here
        self._setupScrolling(self._shapeCanvas)

        self.SetTopWindow(self._wxFrame)

        self._wxFrame.SetAutoLayout(True)
        self._wxFrame.Show(True)

        self._wxFrame.SetMinSize(NOMINAL_SIZE)
        return True

    def _setupScrolling(self, shapeCanvas):
        self.maxWidth:  int  = DEFAULT_WIDTH
        self.maxHeight: int = int(self.maxWidth / A4_FACTOR)  # 1.41 is for A4 support

        nbrUnitsX: int = int(self.maxWidth / PIXELS_PER_UNIT_X)
        nbrUnitsY: int = int(self.maxHeight / PIXELS_PER_UNIT_Y)
        initPosX:  int = 0
        initPosY:  int = 0
        shapeCanvas.SetScrollbars(PIXELS_PER_UNIT_X, PIXELS_PER_UNIT_Y, nbrUnitsX, nbrUnitsY, initPosX, initPosY, False)


if __name__ == '__main__':

    testApp: NoScrollShapeCanvas = NoScrollShapeCanvas()

    testApp.MainLoop()

I was just working on a similar idea in that I already have a subclass of ShapeCanvas and was going to insert additionally behavior in the OnMouseEvent handler. Thanks for following up with me on this.

Here is the final demonstration version of the scrolling shape canvas. It does not scroll super smooth in this code. But, when I use the same code in my app I get smoother scrolling. Do not know why.

from enum import Enum

from wx import DEFAULT_FRAME_STYLE
from wx import FRAME_FLOAT_ON_PARENT
from wx import HORIZONTAL
from wx import VERTICAL

from wx import App
from wx import MouseEvent
from wx import Point
from wx import Size

from wx.lib.ogl import Diagram
from wx.lib.ogl import OGLInitialize
from wx.lib.ogl import ShapeCanvas

from wx.lib.sized_controls import SizedFrame
from wx.lib.sized_controls import SizedPanel

NOMINAL_SIZE: Size = Size(1024, 720)

DEFAULT_WIDTH: int   = 16000
A4_FACTOR:     float = 1.41

PIXELS_PER_UNIT_X: int = 20
PIXELS_PER_UNIT_Y: int = 10


class WheelAxis(Enum):
    WHEEL_AXIS_VERTICAL   = 0
    WHEEL_AXIS_HORIZONTAL = 1

    @classmethod
    def toEnum(cls, value: int) -> 'WheelAxis':

        if value == 0:
            return WheelAxis.WHEEL_AXIS_VERTICAL
        elif value == 1:
            return WheelAxis.WHEEL_AXIS_HORIZONTAL
        else:
            assert False, f'Unknown wheel axis: {value=}'


class ScrollingShapeCanvas(ShapeCanvas):

    def OnMouseEvent(self, mouseEvent: MouseEvent):

        rotation:  int       = mouseEvent.GetWheelRotation()
        wheelAxis: WheelAxis = WheelAxis.toEnum(value=mouseEvent.GetWheelAxis())
        inverted:  bool      = mouseEvent.IsWheelInverted()

        if not inverted:
            rotation = -rotation

        yScrollPosition: int = self.GetScrollPos(VERTICAL)
        xScrollPosition: int = self.GetScrollPos(HORIZONTAL)

        if wheelAxis == WheelAxis.WHEEL_AXIS_VERTICAL:
            yScrollPosition += rotation
        else:
            xScrollPosition += rotation

        self.Scroll(Point(x=xScrollPosition, y=yScrollPosition))

        super().OnMouseEvent(evt=mouseEvent)


class ScrollingShapeCanvasApp(App):
    def __init__(self):
        super().__init__(redirect=False)    # This calls OnInit()

        self._wxFrame = None
        self._shapeCanvas = None

    def OnInit(self):
        OGLInitialize()
        self._wxFrame = SizedFrame(parent=None, title='Bogus', size=NOMINAL_SIZE, style=DEFAULT_FRAME_STYLE | FRAME_FLOAT_ON_PARENT)

        sizedPanel: SizedPanel = self._wxFrame.GetContentsPane()
        sizedPanel.SetSizerProps(expand=True, proportion=1)

        self._shapeCanvas = ScrollingShapeCanvas(sizedPanel)
        self._shapeCanvas.SetDiagram(diag=Diagram())    # incestuous behavior here
        self._setupScrolling(self._shapeCanvas)
        # noinspection PyUnresolvedReferences
        self._shapeCanvas.SetSizerProps(expand=True, proportion=1)

        self.SetTopWindow(self._wxFrame)

        self._wxFrame.SetAutoLayout(True)
        self._wxFrame.Show(True)

        self._wxFrame.SetMinSize(NOMINAL_SIZE)
        return True

    def _setupScrolling(self, shapeCanvas):
        self.maxWidth:  int = DEFAULT_WIDTH
        self.maxHeight: int = round(self.maxWidth / A4_FACTOR)  # 1.41 is for A4 support

        nbrUnitsX: int = self.maxWidth // PIXELS_PER_UNIT_X
        nbrUnitsY: int = self.maxHeight // PIXELS_PER_UNIT_Y
        initPosX:  int = 0
        initPosY:  int = 0
        shapeCanvas.SetScrollbars(PIXELS_PER_UNIT_X, PIXELS_PER_UNIT_Y, nbrUnitsX, nbrUnitsY, initPosX, initPosY, False)


if __name__ == '__main__':

    testApp: ScrollingShapeCanvasApp = ScrollingShapeCanvasApp()

    testApp.MainLoop()