Implementing Custom Event Filters for QGIS Map Canvas Interactions

Learn how to subclass QObject, override eventFilter(), and attach a custom event filter to QgsMapCanvas to intercept mouse, keyboard, and wheel events before…

To intercept raw input before QGIS processes it, subclass QObject, override eventFilter(), and attach the filter to QgsMapCanvas via installEventFilter(). This approach gives your plugin first access to low-level Qt events — mouse, keyboard, wheel, and focus — without modifying built-in map tools or QGIS core code. It is covered in full as part of the Signal and Slot Event Handling in QGIS guide, which sits within the broader PyQGIS Core Architecture & Data Handling documentation.

Complete Runnable Implementation

The class below is a drop-in template for QGIS 3.x. It logs left-click coordinates in map units, suppresses the right-click context menu, and gates wheel zoom behind a lockable flag. The install() / remove() pair keeps lifecycle management explicit and safe.

python
from qgis.PyQt.QtCore import QObject, QEvent, Qt
from qgis.PyQt.QtGui import QMouseEvent, QWheelEvent
from qgis.gui import QgsMapCanvas
from qgis.utils import iface


class MapCanvasEventFilter(QObject):
    """Intercept and selectively consume map canvas input events.

    Attach via install(); always detach via remove() during plugin unload
    to avoid dangling references and potential segmentation faults.
    """

    def __init__(self, canvas: QgsMapCanvas) -> None:
        super().__init__()
        self.canvas = canvas
        self.zoom_locked: bool = False
        self._installed: bool = False

    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------

    def install(self) -> None:
        """Register this filter on the canvas event loop (idempotent)."""
        if not self._installed:
            self.canvas.installEventFilter(self)
            self._installed = True

    def remove(self) -> None:
        """Deregister this filter safely; call from plugin.unload()."""
        if self._installed:
            self.canvas.removeEventFilter(self)
            self._installed = False

    # ------------------------------------------------------------------
    # Core filter logic
    # ------------------------------------------------------------------

    def eventFilter(self, obj: QObject, event: QEvent) -> bool:
        """Called by Qt for every event on the watched object.

        Returns True to consume the event (stop propagation),
        or False to forward it to QgsMapCanvas and subsequent handlers.
        """
        # Guard: only intercept events meant for our canvas
        if obj is not self.canvas:
            return super().eventFilter(obj, event)

        event_type = event.type()

        if event_type == QEvent.MouseButtonPress:
            mouse_event: QMouseEvent = event  # type: ignore[assignment]
            if mouse_event.button() == Qt.LeftButton:
                # Convert screen coords → active map CRS coordinates
                # See: coordinate-transformations-and-crs-handling
                transform = self.canvas.getCoordinateTransform()
                pt = transform.toMapCoordinates(mouse_event.pos())
                print(f"Left click at map coords: {pt.x():.4f}, {pt.y():.4f}")
            elif mouse_event.button() == Qt.RightButton:
                # Consume event — suppress the default context menu
                return True

        elif event_type == QEvent.Wheel:
            if self.zoom_locked:
                return True  # Block zoom entirely when locked

        # Forward everything else to QgsMapCanvas default handling
        return super().eventFilter(obj, event)


# ------------------------------------------------------------------
# Integration (plugin __init__.py or console)
# ------------------------------------------------------------------
# canvas = iface.mapCanvas()
# _filter = MapCanvasEventFilter(canvas)
# _filter.install()
#
# To lock zoom:
#   _filter.zoom_locked = True
#
# During plugin unload:
#   _filter.remove()

Event-Propagation Flow

The diagram below shows the three-stage path a raw Qt input event takes from the OS to the canvas renderer and how your filter sits in the chain.

Qt Event Propagation with Custom Event Filter A flowchart showing how a Qt input event passes through an installed event filter before reaching QgsMapCanvas. The filter can either consume the event or forward it. OS Input Event mouse · key · wheel · touch MapCanvasEventFilter eventFilter(obj, event) consume? return True / False True Event consumed False QgsMap Canvas Map render / tool handler

Architecture Breakdown

QObject.eventFilter(obj, event) — the filter contract

Qt calls eventFilter(obj, event) for every event dispatched to a watched object. The obj argument is the QgsMapCanvas instance (or another watched widget if the filter is installed on multiple targets). The event argument is a polymorphic QEvent subclass; always check event.type() before casting — casting to the wrong subtype will silently return garbage attribute values.

Returning True terminates the event chain immediately: no other filters run, and QgsMapCanvas.event() is never called. Returning False — or, more correctly, calling super().eventFilter(obj, event) — hands the event to the next filter in the installation stack and eventually to Qt’s default QgsMapCanvas handler.

installEventFilter() / removeEventFilter() — ownership semantics

installEventFilter(filter) places the filter object into a singly-linked list owned by the watched widget. The filter is not reparented — you retain ownership. Calling removeEventFilter(filter) detaches from that list without deleting the object. If you destroy the filter while it is still installed (e.g. by letting a local variable fall out of scope), Qt will attempt to call a deallocated method on the next input event, typically causing a segfault or RuntimeError: wrapped C/C++ object has been deleted. Always call remove() before the filter object is garbage-collected.

QgsMapCanvasMap.getCoordinateTransform() — screen to map CRS

canvas.getCoordinateTransform() returns a QgsMapToPixel object calibrated to the current view extent and output device. Use toMapCoordinates(QPoint) to convert a pixel position from event.pos() into map-unit coordinates in the project CRS. If on-the-fly reprojection is active — see Coordinate Transformations and CRS Handling — the returned coordinates are in the canvas CRS, not necessarily the layer’s native CRS. Apply an additional QgsCoordinateTransform if you need layer-native units.

Registration and Plugin Integration

Wire the filter into a standard QGIS plugin skeleton so it is created, installed, and torn down in the right lifecycle hooks.

python
from qgis.gui import QgsMapCanvas
from qgis.utils import iface


class MyPlugin:
    """Minimal plugin showing event-filter lifecycle integration."""

    def __init__(self, iface_ref) -> None:
        self.iface = iface_ref
        self._canvas_filter: MapCanvasEventFilter | None = None

    def initGui(self) -> None:
        """Called by QGIS when the plugin is loaded."""
        canvas: QgsMapCanvas = self.iface.mapCanvas()
        self._canvas_filter = MapCanvasEventFilter(canvas)
        self._canvas_filter.install()

    def unload(self) -> None:
        """Called by QGIS when the plugin is unloaded.

        Always remove the filter first to prevent dangling C++ references.
        See: plugin-lifecycle-and-resource-management
        """
        if self._canvas_filter is not None:
            self._canvas_filter.remove()
            self._canvas_filter = None

For standalone console scripts (no plugin manager), call install() after QApplication and QgsApplication are initialised, and remove() before QgsApplication.exitQgis().

Production Best Practices

  • Keep eventFilter() fast. The method runs synchronously on the GUI thread for every input event — including mouse-move events that fire dozens of times per second. Offload any non-trivial logic with QTimer.singleShot(0, callback) or hand it to a background task via QgsTask.
  • Always delegate unhandled events. Returning False without calling super().eventFilter(obj, event) can skip other installed filters in the chain. Use super() to guarantee correct Qt internal routing.
  • Guard the obj identity. If you attach the same filter to multiple widgets (e.g. canvas and an overlay panel), check obj is self.canvas at the top of eventFilter() before inspecting event.type().
  • Avoid SIP ownership traps. Do not store raw QEvent references beyond the function call; Qt recycles event objects. Extract the values you need (button, position, modifiers) and discard the reference.
  • Test the lock path explicitly. Use QTest.mouseClick() and QTest.keyClick() from qgis.PyQt.QtTest to simulate input in unit tests — do not rely on manual canvas interaction to validate blocking logic.
  • Version guard for Qt6. QGIS 3.38+ ships with Qt6 on some platforms. QEvent.MouseButtonPress is a plain int enum value in both Qt5 and Qt6 via PyQGIS, but event.pos() returns QPointF in Qt6; call event.pos().toPoint() for backward compatibility if you support both.
  • Coordinate system awareness. Screen coordinates (event.pos()) are device pixels. Always pass them through canvas.getCoordinateTransform().toMapCoordinates() rather than inferring map units manually, especially in projects with on-the-fly reprojection from the coordinate transformations subsystem.

When to Choose Event Filters vs Signals

Event filters provide pre-processing control and input suppression — you intercept events before QGIS acts on them. Signal and slot connections provide post-processing reactions — you respond after the canvas has already translated input into map state changes (scale, selection, layer visibility). Mixing both patterns is idiomatic: a filter modifies or gates raw input, emits a custom signal, and downstream components consume it via standard slot connections. This keeps concerns separated while retaining full control over the interaction lifecycle.

For related canvas rendering customisation — drawing overlays, rulers, or highlights directly on the map surface — see the Custom Map Canvas Overlays and Rendering guide.