Building Exclusive Tool Modes with QActionGroup

Give a QGIS plugin mutually exclusive tool modes: a QActionGroup for exclusivity, reacting to toggled rather than triggered, syncing with the canvas…

TL;DR: put every checkable mode action into one QActionGroup with setExclusive(True), react to toggled(bool) rather than triggered(), and connect to the canvas’s mapToolSet signal so your buttons uncheck themselves when QGIS switches to a tool of its own. This page is part of the integrating toolbars and menu actions guide.

Complete Runnable Code

python
"""Two mutually exclusive map-tool modes on a plugin toolbar."""
from qgis.gui import QgsMapTool, QgsMapToolEmitPoint
from qgis.PyQt.QtGui import QIcon
from qgis.PyQt.QtWidgets import QAction, QActionGroup


class ModeToolbar:
    """Owns the mode actions, the group that makes them exclusive, and the tools."""

    def __init__(self, iface, plugin_dir: str):
        self.iface = iface
        self.canvas = iface.mapCanvas()
        self.actions: list[QAction] = []
        self.tools: dict[QAction, QgsMapTool] = {}

        self.group = QActionGroup(iface.mainWindow())
        self.group.setExclusive(True)          # at most one checked at a time

        self._add_mode("Identify", QgsMapToolEmitPoint(self.canvas),
                       f"{plugin_dir}/icons/identify.svg")
        self._add_mode("Measure", QgsMapToolEmitPoint(self.canvas),
                       f"{plugin_dir}/icons/measure.svg")

        # QGIS can switch tools without touching our buttons — stay in step
        self.canvas.mapToolSet.connect(self._on_map_tool_set)

    def _add_mode(self, label: str, tool: QgsMapTool, icon_path: str) -> None:
        action = QAction(QIcon(icon_path), label, self.iface.mainWindow())
        action.setCheckable(True)
        action.setToolTip("%s mode" % label)
        action.toggled.connect(lambda checked, a=action: self._on_toggled(a, checked))

        self.group.addAction(action)
        self.iface.addToolBarIcon(action)
        self.actions.append(action)
        self.tools[action] = tool

    def _on_toggled(self, action: QAction, checked: bool) -> None:
        if checked:
            self.canvas.setMapTool(self.tools[action])
        elif self.canvas.mapTool() is self.tools[action]:
            self.canvas.unsetMapTool(self.tools[action])

    def _on_map_tool_set(self, new_tool, old_tool=None) -> None:
        """Uncheck our actions when something else takes over the canvas."""
        ours = new_tool in self.tools.values()
        if not ours:
            self.group.setExclusive(False)     # allow setting them all to False
            for action in self.actions:
                action.setChecked(False)
            self.group.setExclusive(True)

    def unload(self) -> None:
        try:
            self.canvas.mapToolSet.disconnect(self._on_map_tool_set)
        except TypeError:
            pass                                # already disconnected
        for action in self.actions:
            if self.canvas.mapTool() is self.tools.get(action):
                self.canvas.unsetMapTool(self.tools[action])
            self.iface.removeToolBarIcon(action)
            self.group.removeAction(action)
        self.actions.clear()
        self.tools.clear()
What one mode switch actually does A four-step switch: the user triggers a checkable action, the action group unchecks the previous one, the toggled slot deactivates the old map tool, and the new tool is set on the canvas. user clicks a checkable action group unchecks the previous one toggled(bool) your slot runs setMapTool() canvas switches exclusive signal apply

Architecture Breakdown

setExclusive(True) does the unchecking for you

A QActionGroup with exclusivity on guarantees at most one of its checkable actions is checked. Clicking a second mode unchecks the first automatically, and the toggled signal fires for both — once with False for the outgoing mode and once with True for the incoming one.

That double signal is why the slot branches on checked. Handling only the True case leaves the outgoing tool active, and the canvas ends up with a tool no button claims.

toggled(bool) rather than triggered()

triggered() fires when the action is activated and tells you nothing about the resulting state, which means the slot has to ask the action what happened. toggled(bool) carries the state directly and fires for programmatic changes too — including the ones the group makes on your behalf.

Three ways to express exclusivity, and what each costs A grid comparing a QActionGroup, manual uncheck logic and separate toggle actions, showing how much code each needs and how each behaves when a mode is deactivated elsewhere. code needed when QGIS changes the tool QActionGroup two lines you must sync it manual unchecking grows with modes usually forgotten independent toggles none two modes appear active

The middle row of that table is what most plugins start with and regret: manual unchecking grows quadratically with the number of modes and is always one mode behind.

The mapToolSet signal closes the loop

The exclusivity group knows about your actions. It knows nothing about the canvas, so when a user picks a QGIS tool from the main toolbar, your button stays checked while your tool is no longer active — an interface that is lying about its own state.

Connecting to mapToolSet and unchecking when the new tool is not one of yours keeps the buttons honest. Note the temporary setExclusive(False): an exclusive group will not let you uncheck the last checked action, so it has to be relaxed for that instant.

The Three Ways a Mode Ends

A mode does not only end when the user picks another one, and the two less obvious endings are where the interface starts telling lies about its own state.

Keeping the buttons honest A decision tree for the three ways a mode can end: the user picks another mode, QGIS sets a different map tool, or the plugin unloads — each of which must leave the buttons unchecked. How did this mode end? another mode the group handles it nothing to do QGIS changed tool mapToolSet signal uncheck yours plugin unload unset the tool then remove actions

Two of the three need code. The user switching to another of your modes is handled by the group. QGIS switching to its own tool needs the mapToolSet connection. And unloading needs both the tool unset and the actions removed, in that order — unsetting after removal means calling into an action that no longer exists.

Modes and the Rest of the Interface

A mode is a claim about what the next click will do, so anything else in the plugin that changes that claim has to keep the buttons in step. Two cases come up repeatedly.

The first is a dialog that activates a mode as a side effect — “pick a point on the map” buttons are the usual form. Those should set the corresponding action’s checked state rather than calling setMapTool() directly, so the group and the toolbar both learn about it. Calling the canvas directly leaves an active tool that no button reflects.

The second is a mode that ends by itself, such as a one-shot pick that deactivates after a single click. The tool must uncheck its own action when it finishes, or the user is left looking at a pressed button for a mode that is no longer listening. Both cases are the same underlying rule: the action is the state, and the tool follows it rather than the other way round.

Production Best Practices

  • One group per set of mutually exclusive modes, not one group for every checkable action in the plugin.
  • Connect to toggled, branch on the boolean, and handle the False case.
  • Sync with mapToolSet, or your buttons will misreport the active tool.
  • Relax exclusivity before unchecking everything, then restore it.
  • Unset the tool before removing the action during unload.
  • Give each mode a tooltip, since an icon-only toolbar with three modes is otherwise a guessing game.

Frequently Asked Questions

Why can I not uncheck the last action?

Because that is what exclusive means to Qt: one member of the group is always checked once any has been. Setting setExclusive(False) temporarily, unchecking, and restoring it is the standard workaround, and it is what the example does when QGIS takes over the canvas.

Should the group live on the plugin or the main window?

Parent it to the main window, as the example does, so Qt’s ownership tree is sensible — but keep a Python reference on the plugin as well, because you need to remove actions from it during unload. Parenting to the plugin object is not possible unless the plugin is a QObject, which most are not.

Do menu entries and toolbar buttons stay in step?

Yes, if they are the same QAction. An action added to both a toolbar and a menu is one object with one checked state, so the two views cannot disagree. Creating two separate actions for the same mode is the mistake that makes them diverge.

How do I add a mode that is not a map tool?

The same pattern works for any mutually exclusive state — a display mode, a filter mode — with the slot doing whatever that mode means instead of calling setMapTool(). What changes is that there is no mapToolSet equivalent, so nothing external can take the mode away, and the group alone is sufficient.

Should the active mode be remembered between sessions?

Usually not. A mode is a transient interaction state, and restoring one at start-up means the user opens QGIS with a tool already active that they did not choose. Remembering the mode’s configuration — a snapping tolerance, a units choice — is a different question and usually worth doing.

Do disabled actions stay in the group?

Yes. Disabling an action does not remove it from the group, and a disabled action that is still checked keeps its claim on exclusivity. When a mode becomes unavailable — no suitable layer loaded, for instance — uncheck it as well as disabling it, or the group will refuse to let another mode take over cleanly.

Re-enabling later restores the button without restoring the checked state, which is the behaviour users expect: availability and activity are different things.

What happens if two plugins both set a map tool?

The last one wins; the canvas has exactly one active tool. Both plugins will get a mapToolSet signal, which is precisely why handling it matters: the plugin that lost should uncheck its button rather than continue to claim the canvas.