Dynamic mouse events for PySide

A static event model

PySide, one of the Python bindings for Qt, uses the same event model as the C++ version of Qt - one that is designed for static languages. The problem with this event model is that the semantic of your code is defined by the event. For example if you want to pan a view when the middle button is clicked, you would have to implement mousePressEvent, mouseReleaveEvent and mouseMoveEvent in this way:

class MyView(QtGui.QGraphicsView):
    def __init__(self, parent=None):
        QtGui.QGraphicsView.__init__(self, parent)
        self._mouse_press = None

    def mousePressEvent(self, event):
        if event.button() == QtCore.Qt.MidButton:
          self._middle_press = (event.pos().x(), event.pos().y())

    def mouseReleaseEvent(self, event):
        self._middle_press = None

    def mouseMoveEvent(self, event):
        if self._middle_press is not None:
            pos = (event.pos().x(), event.pos().y())
            delta_x = pos[0] - self._middle_press[0]
            delta_y = pos[1] - self._middle_press[1]
            h = self.horizontalScrollBar()
            v = self.verticalScrollBar()
            h.setValue(h.value() + delta_x)
            v.setValue(v.value() + delta_y)
            self._middle_press = pos

This could be implemented in various different ways, but the one thing that remains is that we must spread the logic between three calls, mousePressEvent, mouseReleaseEvent and mouseMoveEvent, which are named after the event being triggered, not after what the functionality actually does. This means that there is no way to tell what happens without reading the whole code line by line.

Implementing a more dynamic model

What we want, instead of implementing something called mouseMoveEvent is to implement something called scroll_view - after what it does, not the way it was invoked - and get that called, with the position delta, when the mouse is moved with the middle button down. Something like this:

add_mouse_handler(
    event={
        'event': 'move',
        'button': 'middle'
    },
    callback=self.scroll_view,
    args=[MouseState('delta_x'), MouseState('delta_y')]
)

What this does is self-explanatory: when the mouse is moved with the middle button pressed, invoke self.scroll_view with the mouse position delta. Now our class only needs to implement one method, scroll_view, named after what it actually does, and is invoked with meaninful parameters.

The full class so implemented would look like:

class MyView(MouseHandler, QtGui.QGraphicsView):
    def __init__(self, parent=None):
        QtGui.QGraphicsView.__init__(self, parent)
        MouseHandler.__init__(self, parent_class=QtGui.QGraphicsView)
        self.add_mouse_handler(
            event={
                'event': 'move',
                'button': 'middle'
            },
            callback=self.scroll_view,
            args=[MouseState('delta_x'), MouseState('delta_y')]
        )

    def scroll_view(self, delta_x, delta_y):
        h = self.horizontalScrollBar()
        v = self.verticalScrollBar()
        h.setValue(h.value() + delta_x)
        v.setValue(v.value() + delta_y)

It's not just much shorter - it's also much clearer. None of the actual business logic was moved out, scroll_view is generic and re-usable, and the add_mouse_handler call can be understood without knowing the underlying library.

The mouse state

The MouseHandler mixin works by keeping a comprehensive state of the mouse, which includes:

  • The event name ('press', 'release', 'double-click', 'wheel', 'move', 'enter' or 'leave');
  • The current position of the mouse;
  • The delta of the mouse movement;
  • The currently pressed button;
  • The mouse position at which the button was pressed;
  • The delta value of the wheel move;
  • Whether the mouse is over the current element (for 'hover' events);
  • Which keyboard modfiers are currently pressed.

The MouseHandler makes it possible to react to any combination of values in the mouse state - for example when the mouse is being moved with the middle button down and the control key pressed. And the handler being called can be passed a list of arguments, any of which may come from the mouse state - so we can pass the mouse co-ordinates, the mouse delta, etc. This can be combined with static arguments - so if our scroll_view method had a last argument called relative and defaulting to False, we could have set the argument list to [MouseState('delta_x'), MouseState('delta_y'), True]

To increase the expressiveness of the code, classes can event add their own custom mouse states - for instance you could add pressed_corner to define which corner of a box was pressed. You can then fire different move events depending on this.

The code!

At this stage I'm not sure whether I'll support this in the long term - that depends on whether I get involved in more PySide projects. For now, I will share the code here - I might create a repo at some point (check my GitHub page). Note that the code is shared as-is for inspiration, rather than something you can just pull and re-use. Here goes:

from PySide import QtCore


class MouseState(object):
    """Class used to represent a property of a mouse state.

    This is used to represent callback arguments that must be defined when the event is fired - eg. MouseState('x')
    will represent the 'x' property of the mouse state when the event is fired

    Parameters
    ----------
    state : str
        The mouse state to represent

    Attributes
    ----------
    state : str
        The mouse state to represent
    """
    def __init__(self, state):
        self.state = state


class MouseHandler(object):
    """Mixin used to map mouse events to methods.

    This mixin uses the following event names: 'press', 'release', 'double-click', 'wheel', 'move', 'enter', 'leave'.
    The mouse state contains the following properties:
        event : str
            The event name
        x : int
            Current X mouse position
        y : int
            Current Y mouse position
        button : str, None
            The current button state. One of 'left', 'right', 'middle', None
        pressed_x : int, None
            The X coordinate where the mouse was pressed (if applicable) or None
        pressed_y : int, None
            The Y coordinate where the mouse was pressed (if applicable) or None
        delta_x : int
            The X delta of the mouse movement
        delta_y : int
            The Y delta of the mouse movement
        wheel_delta : int
            The delta sent by the mouse wheel on wheel events or None
        over : bool
            True if the mouse is over the current item, false if not
        modifier : int
            A Qt key modifier constant

    Example
    -------
    To use MouseEvents, you need to inherit from it and call it's initializer specifying the class which holds
    the default Mouse events implementation:

        class GraphicsView(MouseEvents, QtGui.QGraphicsView):
        def __init__(self, parent=None):
            QtGui.QGraphicsView.__init__(self, parent)
            MouseEvents.__init__(self, parent_class=QtGui.QGraphicsView)

    You can then add handlers for any combination of events and mouse state:

            self.add_mouse_handler({
                'event': 'move',
                'button': 'middle'
            }), self.scroll_view, args=[MouseState('x'), MouseState('y'), True])
            self.add_mouse_handler({
                'event': 'wheel',
                'button': None,
                'modifier': QtCore.Qt.ControlModifier
            }, self.zoom)


    The functions will be called with event-specific arguments:
        def scroll_view(self, x, y, refresh=False):
            h = self.horizontalScrollBar()
            v = self.verticalScrollBar()
            delta = self.get_mouse_state('delta')
            h.setValue(h.value() + delta[0])
            v.setValue(v.value() + delta[1])

    Notes
    -----
    - The mixin implements Qt mouse events methods, so classes should not implement their own.
    - Some elements, such as elements derived from QtGraphicsItem, will not trigger mouse move
      events unless a button is pressed. To change that behaviour, you need to call
      `setAcceptHoverEvents` to True. Qt will then fire different events whether a button is
      pressed or not. The MouseEvents abstracts this behaviour - so you can rely on the event
      {'event': 'move', 'over': True} to work in both instances.
    """
    def __init__(self, parent_class):
        self.parent_class = parent_class

        self._last_position = (0, 0)
        self._mouse_state = {
            'event': None,
            'button': None,
            'x': 0,
            'y': 0,
            'pressed_x': None,
            'pressed_y': None,
            'delta_x': 0,
            'delta_y': 0,
            'over': False,
            'modifier': QtCore.Qt.NoModifier
        }

        self._handlers = {}

    def add_mouse_handler(self, event, callback, delegate=False, args=None):
        """Add a new mouse handler

        Parameters
        ----------
        event : str, object
            see `_get_event`
        callback : function
            Function to call on event. If delegate is True, the function should return True to
            allow the event to propagate, False otherwise.
        delegate : bool
            If False (the default), events will not propagate when the handler is invoked.
            If True, the value of the callback function will determine whether events should
            propagate (True to propagate)
        args : list, None
            Additional arguments will be passed to the callback methods. Arguments that are
            instances of MouseState will be set to the given mouse state when the event is fired.
        """
        (event, state) = self._get_event(event)
        if args is None:
            args = []
        if event not in self._handlers:
            self._handlers[event] = []
        self._handlers[event].append((callback, state, delegate, args))

    def get_mouse_state(self, prop=None):
        """Return the mouse state

        Parameters
        ----------
        prop : str, None
            The name of a property or None

        Returns
        -------
        object
            Either a given property (if prop is not None), or the whole mouse state object
        """
        if prop is None:
            return self._mouse_state
        else:
            return self._mouse_state[prop]

    def set_mouse_state(self, prop, value):
        """Set a mouse state value

        This may be called to set custom mouse state values. It can also be
        used to override existing mouse state values, though those may get
        overwritten at the next update.

        Parameters
        ----------
        prop : str
            The name of a property
        value : object
            The value to set
        """
        self._mouse_state[prop] = value

    def _get_event(self, event):
        """Return a (event, state) tuple for the given event

        Parameters
        ----------
        event : str, dictionary
            May be an event name, or a dictionary mapping 'event' and any other mouse_state properties to values.

        Returns
        -------
        tuple
            An (event, state) tuple
        """
        event_str = event
        state = {}
        if isinstance(event, dict):
            event_str = event['event']
            state = event
            if 'modifier' in state:
                state['modifier'] = int(state['modifier'])

        return event_str, state

    def _handle_event(self, event, event_name, default_callback):
        """Handle an event by calling appropriate handlers

        Parameters
        ----------
        event : QtEvent
            The event that triggered the call
        event_name : str
            Event name. See `_get_event`
        default_callback : function
            Function to call for propagation
        """
        # Get handlers
        try:
            handlers = self._handlers[event_name]
        except KeyError:
            default_callback(self, event)
            return
        # Set non-event specific mouse state
        self._mouse_state['event'] = event_name
        self._mouse_state['modifier'] = int(event.modifiers())
        self._mouse_state['x'] = event.pos().x()
        self._mouse_state['y'] = event.pos().y()
        self._mouse_state['delta_x'] = self._last_position[0] - self._mouse_state['x']
        self._mouse_state['delta_y'] = self._last_position[1] - self._mouse_state['y']
        self._last_position = (self._mouse_state['x'], self._mouse_state['y'])
        # Call handlers that match the state
        propagate = True
        for (callback, state, delegate, handler_args) in handlers:
            cancel = False
            for prop in state:
                if self._mouse_state[prop] != state[prop]:
                    cancel = True
                    break
            if cancel:
                continue
            args = []
            for a in handler_args:
                if isinstance(a, MouseState):
                    args.append(self.get_mouse_state(a.state))
                else:
                    args.append(a)
            r = callback(*args)
            if delegate:
                propagate = propagate & r
            else:
                propagate = False
        # Propagate or accept the event
        if propagate:
            default_callback(self, event)
        else:
            event.accept()

    def mousePressEvent(self, event):
        """Handle mouse press events

        Parameters
        ----------
        event : QtEvent
        """
        button_map = {
            QtCore.Qt.MidButton: 'middle',
            QtCore.Qt.LeftButton: 'left',
            QtCore.Qt.RightButton: 'right'
        }
        self._mouse_state['pressed_x'] = event.pos().x()
        self._mouse_state['pressed_y'] = event.pos().y()
        self._mouse_state['button'] = button_map[event.button()]

        self._handle_event(event, 'press', self.parent_class.mousePressEvent)

    def mouseMoveEvent(self, event):
        """Handle mouse move events

        Parameters
        ----------
        event : QtEvent
        """
        self._handle_event(event, 'move', self.parent_class.mouseMoveEvent)

    def mouseReleaseEvent(self, event):
        """Handle mouse release events

        Parameters
        ----------
        event : QtEvent
        """
        self._handle_event(event, 'release', self.parent_class.mouseReleaseEvent)
        self._mouse_state['button'] = None
        self._mouse_state['pressed_at'] = None

    def mouseDoubleClickEvent(self, event):
        """Handle mouse double click events

        Parameters
        ----------
        event : QtEvent
        """
        self._handle_event(event, 'double-click', self.parent_class.mouseDoubleClickEvent)

    def hoverEnterEvent(self, event):
        """Handle mouse hover enter events

        Parameters
        ----------
        event : QtEvent
        """
        self._mouse_state['over'] = True
        self._handle_event(event, 'enter', self.parent_class.hoverEnterEvent)

    def hoverMoveEvent(self, event):
        """Handle hoverMoveEvent

        Parameters
        ----------
        event : QtEvent
        """
        over = self._mouse_state['over']
        self._mouse_state['over'] = True
        self._handle_event(event, 'move', self.parent_class.hoverMoveEvent)
        self._mouse_state['over'] = over

    def hoverLeaveEvent(self, event):
        """Handle mouse hover enter events

        Parameters
        ----------
        event : QtEvent
        """
        self._mouse_state['over'] = False
        self._handle_event(event, 'leave', self.parent_class.hoverLeaveEvent)

    def wheelEvent(self, event):
        """Handle mouse hover enter events

        Parameters
        ----------
        event : QtEvent
        """
        self._mouse_state['wheel_delta'] = event.delta()
        self._handle_event(event, 'wheel', self.parent_class.wheelEvent)
        self._mouse_state['wheel_delta'] = None

It is fairly straightforward to write something similar for keyboard events.