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.
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.
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.
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 withQTimer.singleShot(0, callback)or hand it to a background task via QgsTask. - Always delegate unhandled events. Returning
Falsewithout callingsuper().eventFilter(obj, event)can skip other installed filters in the chain. Usesuper()to guarantee correct Qt internal routing. - Guard the
objidentity. If you attach the same filter to multiple widgets (e.g. canvas and an overlay panel), checkobj is self.canvasat the top ofeventFilter()before inspectingevent.type(). - Avoid SIP ownership traps. Do not store raw
QEventreferences 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()andQTest.keyClick()fromqgis.PyQt.QtTestto 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.MouseButtonPressis a plainintenum value in both Qt5 and Qt6 via PyQGIS, butevent.pos()returnsQPointFin Qt6; callevent.pos().toPoint()for backward compatibility if you support both. - Coordinate system awareness. Screen coordinates (
event.pos()) are device pixels. Always pass them throughcanvas.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.
Related
- Signal and Slot Event Handling in QGIS — parent guide covering Qt signal dispatch,
pyqtSignal, andQgsProjectconnection patterns - Coordinate Transformations and CRS Handling — converting screen pixels to map and layer CRS coordinates
- Plugin Lifecycle and Resource Management — correct
initGui()/unload()patterns to prevent memory leaks - Custom Map Canvas Overlays and Rendering — drawing annotations and highlights over the canvas surface
- Asynchronous Task Execution with QgsTask — offloading heavy logic triggered by canvas events to background threads