Custom Map Tools and Canvas Interaction
A plugin that only adds a dialog is a form with a map behind it. The plugins people actually keep installed are the ones that let you point at something — click a parcel to see its history, drag a line to profile the terrain beneath it, rubber-band a box to select what falls inside. All of those are QgsMapTool subclasses, and the API is small enough to learn in an afternoon once the event flow is clear.
This guide sits inside QGIS Plugin Development. It covers the map tool lifecycle, converting screen pixels into map coordinates, drawing transient feedback with rubber bands, identifying the feature under the cursor, and connecting to the signals that tell your plugin the world changed underneath it. It assumes you already have a plugin skeleton — if not, start with Plugin Boilerplate & Structure.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer, running as a desktop application — map tools need a canvas.
- A working plugin skeleton with an action in the toolbar; see Add a Toolbar Button to a QGIS Plugin.
- Familiarity with Qt signals and slots, since everything here is event-driven — Qt Designer for GIS Interfaces covers the basics.
- A vector layer loaded, for the identify examples.
The QgsMapTool lifecycle
A map tool is a plain Python class deriving from QgsMapTool (or QgsMapToolEmitPoint for the common click-a-point case). The canvas holds exactly one active tool at a time and routes mouse and key events to it.
from qgis.gui import QgsMapTool
from qgis.PyQt.QtCore import Qt, pyqtSignal
from qgis.PyQt.QtGui import QCursor
class PointPickTool(QgsMapTool):
"""Emits the map coordinate of every left click."""
pointPicked = pyqtSignal(object)
def __init__(self, canvas):
super().__init__(canvas)
self.canvas = canvas
def activate(self):
super().activate()
self.canvas.setCursor(QCursor(Qt.CrossCursor))
def canvasReleaseEvent(self, event):
if event.button() != Qt.LeftButton:
return
point = self.toMapCoordinates(event.pos())
self.pointPicked.emit(point)
def deactivate(self):
super().deactivate()
self.deactivated.emit()
Breakdown: pyqtSignal(object) lets the tool report results without knowing who is listening, which keeps it reusable — the plugin connects to pointPicked and decides what to do. activate() and deactivate() are called by the canvas when the tool is set or replaced; always call super() so the base class housekeeping still runs. Handling the release rather than the press is the convention: it lets a user press, think better of it, drag away and release harmlessly. Filtering on Qt.LeftButton leaves the right button free for the context menu.
Installing it is two lines, and keeping a reference is mandatory:
self.tool = PointPickTool(self.iface.mapCanvas())
self.tool.pointPicked.connect(self.on_point_picked)
self.iface.mapCanvas().setMapTool(self.tool)
Breakdown: Storing the tool on self prevents Python from garbage-collecting it while the canvas still holds a pointer, which is a classic cause of a hard crash rather than a clean error. To hand control back, call self.iface.mapCanvas().unsetMapTool(self.tool) — usually in the plugin's unload(). Create a Custom Map Tool in PyQGIS walks through wiring it to a checkable toolbar button so the button state follows the active tool.
From screen pixels to map coordinates
Mouse events carry positions in widget pixels with the origin at the canvas's top-left corner and y increasing downward. Map coordinates have their origin wherever the CRS puts it and y increasing upward. The conversion is one call, and getting it wrong produces coordinates that look plausible but are mirrored or wildly out of range.
def canvasMoveEvent(self, event):
point = self.toMapCoordinates(event.pos())
crs = self.canvas.mapSettings().destinationCrs().authid()
self.iface.mainWindow().statusBar().showMessage(
f"{point.x():.2f}, {point.y():.2f} [{crs}]"
)
Breakdown: toMapCoordinates() is a method on QgsMapTool and handles the flip, the pan offset and the current zoom for you. The result is in the canvas CRS, not the layer's — comparing it directly against a layer geometry in a different CRS is the classic source of a click that never matches anything. When you need layer coordinates, use the two-argument form self.toLayerCoordinates(layer, event.pos()), which applies the transform as well. Capture Map Click Coordinates in PyQGIS covers the CRS handling in detail.
Drawing feedback with a rubber band
A QgsRubberBand is a lightweight overlay drawn on top of the canvas. It is not a layer, nothing is stored, and it repaints instantly — exactly what interactive feedback needs.
from qgis.core import QgsWkbTypes
from qgis.gui import QgsRubberBand
from qgis.PyQt.QtGui import QColor
class BoxSelectTool(QgsMapTool):
def __init__(self, canvas):
super().__init__(canvas)
self.canvas = canvas
self.origin = None
self.band = QgsRubberBand(canvas, QgsWkbTypes.PolygonGeometry)
self.band.setColor(QColor(15, 118, 110, 60))
self.band.setStrokeColor(QColor(15, 118, 110))
self.band.setWidth(2)
def canvasPressEvent(self, event):
self.origin = self.toMapCoordinates(event.pos())
self.band.reset(QgsWkbTypes.PolygonGeometry)
def canvasMoveEvent(self, event):
if self.origin is None:
return
current = self.toMapCoordinates(event.pos())
self.band.reset(QgsWkbTypes.PolygonGeometry)
for corner in (
self.origin,
(self.origin.x(), current.y()),
current,
(current.x(), self.origin.y()),
):
self.band.addPoint(QgsPointXY(*corner) if isinstance(corner, tuple) else corner, False)
self.band.closePoints()
def deactivate(self):
self.band.reset(QgsWkbTypes.PolygonGeometry)
super().deactivate()
Breakdown: The geometry type passed to the constructor decides what the band draws — PointGeometry, LineGeometry or PolygonGeometry. The fill colour's fourth argument is alpha, so 60 gives a translucent wash that does not hide the map beneath. addPoint(..., False) suppresses the repaint for every intermediate vertex; closePoints() triggers it once at the end, which keeps dragging smooth. Resetting in deactivate() is what stops an abandoned rectangle from sitting on the canvas for the rest of the session. Highlight a Feature with a Rubber Band in PyQGIS covers the geometry-following variant used for highlighting.
Identifying the feature under the cursor
Clicks are imprecise, so a hit test against an exact point almost never matches. The convention is to build a small search rectangle around the click, sized in map units derived from the current zoom, and query with that.
from qgis.core import QgsFeatureRequest, QgsRectangle
def features_at(self, layer, pos, pixel_radius=6):
"""Features whose geometry falls within pixel_radius of a canvas position."""
point = self.toLayerCoordinates(layer, pos)
radius = self.canvas.mapUnitsPerPixel() * pixel_radius
box = QgsRectangle(
point.x() - radius, point.y() - radius,
point.x() + radius, point.y() + radius,
)
request = QgsFeatureRequest().setFilterRect(box)
return list(layer.getFeatures(request))
Breakdown: toLayerCoordinates() does the pixel conversion and the CRS transform into the layer's own system, which is what makes the rectangle comparable to the stored geometry. mapUnitsPerPixel() converts a comfortable pixel tolerance into map units at the current zoom, so the tool behaves identically whether the user is zoomed to a street or a country. setFilterRect() pushes the bounding-box test down to the provider, and on an indexed source it is close to free — the same principle as the spatial index in Geometry Operations and Spatial Predicates. Note the result is a candidate list from a box test; if you need exact hits, follow up with geometry.intersects(). Identify the Feature Under the Cursor in PyQGIS builds this into a complete identify tool.
Snapping to existing geometry
A digitising tool that lets the user click "close enough" to a vertex and quietly records a point half a metre away produces slivers and gaps. QGIS ships a snapping utility that map tools can borrow, so your tool obeys the same snapping settings the user configured for the built-in tools.
def canvasReleaseEvent(self, event):
match = self.canvas.snappingUtils().snapToMap(event.pos())
point = match.point() if match.isValid() else self.toMapCoordinates(event.pos())
self.pointPicked.emit(point)
Breakdown: snappingUtils() is owned by the canvas and already configured from the project's snapping options, so your tool inherits the user's tolerance, mode and target layers for free. snapToMap() takes the raw pixel position and returns a QgsPointLocator.Match. isValid() is false when nothing was within tolerance, which is why the fall-back to the unsnapped coordinate matters — without it, clicking in empty space would return a null point. The match also carries match.layer() and match.featureId(), so a tool can report what it snapped to, not just where.
Keyboard modifiers and cancelling
Users expect Escape to abort and modifier keys to change behaviour. Both arrive through methods you override, and handling them is what separates a tool that feels native from one that feels like a prototype.
from qgis.PyQt.QtCore import Qt
def keyPressEvent(self, event):
if event.key() == Qt.Key_Escape:
self.origin = None
self.band.reset(QgsWkbTypes.PolygonGeometry)
event.ignore()
def canvasReleaseEvent(self, event):
additive = bool(event.modifiers() & Qt.ShiftModifier)
self.selectionRequested.emit(self.current_rect(), additive)
Breakdown: Calling event.ignore() after handling Escape lets the event continue propagating, so QGIS's own "stop rendering" behaviour still works — swallowing it would break a shortcut the user relies on elsewhere. event.modifiers() is a bit flag tested with &, and Shift-to-add is the selection convention across the whole application, so following it costs one line and saves users from having to learn your tool's own rules.
Reacting to signals
A map tool is only half of the interaction story. The other half is knowing when the project changed — a layer was removed, the selection changed, the user finished an edit — so your plugin does not act on a stale reference.
from qgis.core import QgsProject
project = QgsProject.instance()
project.layersAdded.connect(self.on_layers_added)
project.layerWillBeRemoved.connect(self.on_layer_removed)
layer.selectionChanged.connect(self.on_selection_changed)
layer.editingStopped.connect(self.on_edits_committed)
Breakdown: layerWillBeRemoved fires before the layer is destroyed, which is the only safe moment to drop your reference to it — acting on layersRemoved and then touching the layer crashes. selectionChanged carries the added and removed feature IDs, so a handler does not need to re-read the whole selection. editingStopped fires on both commit and rollback, so check layer.isEditable() if you need to distinguish them.
Every one of these connections must be undone in the plugin's unload(), or a reloaded plugin will have two handlers attached and every action will run twice:
def unload(self):
QgsProject.instance().layersAdded.disconnect(self.on_layers_added)
self.iface.mapCanvas().unsetMapTool(self.tool)
Breakdown: Disconnecting mirrors the connection exactly. unsetMapTool() restores whatever tool was active before, leaving the canvas usable rather than stuck with a dangling tool object. This is the same discipline described in Plugin Boilerplate & Structure, and the reason Reload a QGIS Plugin Without Restarting is worth setting up early — duplicate-handler bugs only appear after a reload. Connect to Layer and Project Signals in PyQGIS lists the signals worth knowing.
A complete measuring tool
Putting the pieces together, here is a tool small enough to read in one sitting that nonetheless behaves properly: it snaps, draws live feedback, reports a running distance, respects Escape, and cleans up after itself.
from qgis.core import QgsDistanceArea, QgsProject, QgsWkbTypes
from qgis.gui import QgsMapTool, QgsRubberBand
from qgis.PyQt.QtCore import Qt, pyqtSignal
from qgis.PyQt.QtGui import QColor, QCursor
class MeasureTool(QgsMapTool):
"""Click to add vertices; reports the running ellipsoidal length."""
lengthChanged = pyqtSignal(float)
def __init__(self, canvas):
super().__init__(canvas)
self.canvas = canvas
self.points = []
self.band = QgsRubberBand(canvas, QgsWkbTypes.LineGeometry)
self.band.setColor(QColor(180, 83, 9))
self.band.setWidth(3)
self.calculator = QgsDistanceArea()
self.calculator.setSourceCrs(
canvas.mapSettings().destinationCrs(),
QgsProject.instance().transformContext(),
)
self.calculator.setEllipsoid(QgsProject.instance().ellipsoid())
def activate(self):
super().activate()
self.canvas.setCursor(QCursor(Qt.CrossCursor))
def canvasReleaseEvent(self, event):
if event.button() == Qt.RightButton:
self._reset()
return
match = self.canvas.snappingUtils().snapToMap(event.pos())
point = match.point() if match.isValid() else self.toMapCoordinates(event.pos())
self.points.append(point)
self.band.addPoint(point, True)
self.lengthChanged.emit(self._length())
def keyPressEvent(self, event):
if event.key() == Qt.Key_Escape:
self._reset()
event.ignore()
def _length(self):
total = 0.0
for start, end in zip(self.points, self.points[1:]):
total += self.calculator.measureLine(start, end)
return total
def _reset(self):
self.points.clear()
self.band.reset(QgsWkbTypes.LineGeometry)
self.lengthChanged.emit(0.0)
def deactivate(self):
self._reset()
super().deactivate()
Breakdown: The QgsDistanceArea is built once in the constructor rather than per click, and configured with both the canvas CRS and the project ellipsoid so measureLine() returns real metres rather than map units — the same distinction covered in Geometry Operations and Spatial Predicates. addPoint(point, True) repaints immediately, which is right here because clicks are infrequent; the False form used earlier is for the high-frequency move handler. Right-click resets rather than opening a menu, matching the built-in measure tool. _reset() is called from three places — right click, Escape and deactivation — which is exactly why it is a method rather than three copies of the same three lines. The tool emits lengthChanged instead of touching a label directly, so a dock widget or a status bar can consume it without the tool knowing which.
Connecting it from the plugin is unchanged from the simple case, and the signal makes the display trivial:
self.measure = MeasureTool(self.iface.mapCanvas())
self.measure.lengthChanged.connect(
lambda metres: self.iface.mainWindow().statusBar().showMessage(f"{metres:,.1f} m")
)
self.iface.mapCanvas().setMapTool(self.measure)
Breakdown: The lambda keeps formatting out of the tool, which is the right split — a tool that returns a number is reusable, one that writes a formatted string into a specific widget is not.
Troubleshooting interaction bugs
Interaction bugs are harder to diagnose than logic bugs because there is no stack trace — the tool simply does nothing, or does something once and then stops. These are the recurring shapes.
- Nothing happens when you click. The tool was never installed, or another tool replaced it. Print
canvas.mapTool()and confirm it is your instance; a checkable toolbar action that was not connected tosetMapTool()looks active while doing nothing. - The first click works, then nothing. Something inside the handler raised. Exceptions in a Qt event handler are printed to the QGIS log rather than surfacing, and the tool keeps running in a half-initialised state. Wrap the handler body while developing and check the Log Messages panel — see Debugging PyQGIS Scripts.
- Coordinates are enormous or negative. A pixel position was used where a map coordinate was expected.
event.pos()is never a map coordinate; it must go throughtoMapCoordinates()ortoLayerCoordinates()first. - The rubber band flickers or lags on drag. Every
addPoint()is repainting. PassFalsefor the update argument on intermediate vertices and let the final call repaint once. - The tool works in one project and not another. Almost always a CRS assumption. A tool that compares canvas coordinates against layer geometry works perfectly while both happen to share a CRS and fails silently the moment they do not.
- Move events never arrive.
QgsMapToolonly receives move events while a button is held unless you opt in. Setself.setCursor(...)and ensure the canvas has mouse tracking enabled, or track state from press and release instead.
A quick way to confirm events are reaching the tool at all is to log the raw position at the top of each handler before any conversion. If nothing is logged the problem is installation; if positions appear but the results are wrong, the problem is conversion.
Key takeaways
- Keep a reference to your tool. The canvas holds a non-owning pointer; letting Python collect the tool crashes QGIS rather than raising.
- Handle release, not press. It gives the user a chance to abort a click by dragging away, which is the behaviour every built-in tool has.
toMapCoordinates()returns canvas-CRS coordinates. UsetoLayerCoordinates(layer, pos)whenever you are about to compare against layer geometry.- Size hit tolerances in pixels, converted through
mapUnitsPerPixel(). A fixed map-unit tolerance behaves completely differently at different zooms. - Rubber bands are overlays, not layers. They cost nothing, but they must be reset in
deactivate()or they linger. - Disconnect every signal in
unload(). Duplicate handlers after a reload are the most confusing plugin bug there is.
Frequently Asked Questions
Why does QGIS crash when I activate my map tool?
Almost always because the tool object was garbage-collected. The canvas keeps a raw pointer to it, so the tool must be stored on the plugin instance — self.tool = MyTool(canvas) — and not created as a local variable inside the function that installs it.
My clicks never match any feature. What is wrong?
Either the coordinates are in the wrong CRS or the tolerance is zero. Use toLayerCoordinates(layer, event.pos()) rather than toMapCoordinates() when comparing against a layer, and build a search rectangle sized from mapUnitsPerPixel() instead of testing an exact point.
Should I subclass QgsMapTool or QgsMapToolEmitPoint?QgsMapToolEmitPoint if all you need is a single clicked coordinate — it gives you a ready-made canvasClicked signal. Subclass QgsMapTool directly when you need drag behaviour, custom feedback, or key handling.
How do I stop my rubber band from staying on the canvas?
Call band.reset(geometry_type) in deactivate(), and again in unload() for safety. A rubber band is owned by the canvas scene, so nothing removes it automatically when your tool goes out of scope.
Why does my plugin react twice to every click after I reload it?
The previous instance's signal connections were never removed. Disconnect every signal you connect in the matching unload() method; a reload creates a second set of handlers on top of the first otherwise.
Related
- QGIS Plugin Development — the guide this page belongs to
- Plugin Boilerplate & Structure
- Qt Designer for GIS Interfaces
- Add a Custom Dock Widget with PyQGIS
- Create a Custom Map Tool in PyQGIS
- Capture Map Click Coordinates in PyQGIS
- Highlight a Feature with a Rubber Band in PyQGIS
- Identify the Feature Under the Cursor in PyQGIS
- Connect to Layer and Project Signals in PyQGIS
- Map Canvas Control and Image Export in PyQGIS