Source code for qgis_macros.macro
# Copyright (c) 2025-2026 macro-qgis-plugin contributors.
#
#
# This file is part of macro-qgis-plugin.
#
# macro-qgis-plugin is free software: you can redistribute it and/or
# modify it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# macro-qgis-plugin is distributed in the hope that it will be
# useful, but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with macro-qgis-plugin. If not, see <https://www.gnu.org/licenses/>.
"""Macro data structures for recording and playing back user interactions.
This module defines the core event types (key, mouse, wheel) and the
:class:`Macro` container that serializes/deserializes recorded sessions.
Example usage::
from qgis_macros.macro import Macro
# Deserialize a macro from a dict (e.g. loaded from JSON)
macro = Macro.deserialize(data)
# Serialize back
data = macro.serialize()
"""
import dataclasses
import logging
from abc import ABC, abstractmethod
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Protocol
from qgis.core import Qgis, QgsApplication, QgsLineString
from qgis.PyQt.QtCore import QEvent, QPoint, Qt
from qgis.PyQt.QtGui import QCursor, QMouseEvent, QWheelEvent
from qgis.PyQt.QtTest import QTest
from qgis.PyQt.QtWidgets import QApplication, QWidget
from qgis_macros import utils
from qgis_macros.constants import (
MAXIMUM_NEAREST_CANDIDATES,
MAXIMUM_PARENT_DEPTH,
)
from qgis_macros.exceptions import WidgetNotFoundError
from qgis_macros.utils import enum_value
LOGGER = logging.getLogger(__name__)
[docs]
@dataclass
class WidgetSpec:
"""Identify a widget by its class name and display text."""
widget_class: str
text: str = ""
[docs]
@staticmethod
def create(widget: QWidget) -> "WidgetSpec":
"""Create a WidgetSpec from an existing widget."""
return WidgetSpec(widget.__class__.__name__, utils.get_widget_text(widget))
[docs]
def matches(self, widget: QWidget) -> bool:
"""Return True if *widget* matches this spec's class and text."""
return (
widget.__class__.__name__ == self.widget_class
and self.text == utils.get_widget_text(widget)
)
[docs]
def get_suitable_widget(
self, point: QPoint, widget: QWidget, level: int = 1
) -> QWidget:
"""Find the nearest child widget matching this spec.
Walk up the widget hierarchy (up to ``MAXIMUM_PARENT_DEPTH`` levels)
searching for a visible child whose class and text match.
Raises:
WidgetNotFoundError: If no matching widget is found.
"""
nearest_candidates = utils.find_nearest_visible_children_of_type(
point, widget, self.widget_class
)
for i, candidate in enumerate(nearest_candidates):
if self.matches(candidate):
return candidate
if i > MAXIMUM_NEAREST_CANDIDATES:
break
if level < MAXIMUM_PARENT_DEPTH and (parent := widget.parent()) is not None:
return self.get_suitable_widget(point, parent, level + 1)
raise WidgetNotFoundError(self.widget_class, self.text)
[docs]
@dataclass(frozen=True)
class WidgetPathNode:
"""A single node in a widget path, identifying a widget within its parent."""
widget_class: str
sibling_index: int
text: str = ""
[docs]
def matches(self, widget: QWidget) -> bool:
"""Check if the given widget matches this node's criteria."""
return (
widget.__class__.__name__ == self.widget_class
and self.text == utils.get_widget_text(widget)
)
[docs]
@dataclass
class WidgetPath:
"""Path from a top-level window down to a target widget.
Each node identifies a widget by its class name, text, and index
among same-class siblings. This allows reliable widget lookup even
when widgets lack objectNames or shift position on screen.
"""
window_title: str
nodes: list[WidgetPathNode]
is_map_canvas: bool = False
[docs]
@staticmethod
def create(widget: QWidget) -> "WidgetPath":
"""Create a WidgetPath from a given widget.
by traversing its parent hierarchy.
"""
is_map_canvas = utils.is_object_map_canvas(widget)
nodes: list[WidgetPathNode] = []
current = widget
while current is not None:
if current.isWindow():
window_title = current.windowTitle()
nodes.reverse()
return WidgetPath(window_title, nodes, is_map_canvas)
parent = current.parentWidget()
if parent is not None:
sibling_index = utils.get_sibling_index(current, parent)
nodes.append(
WidgetPathNode(
widget_class=current.__class__.__name__,
sibling_index=sibling_index,
text=utils.get_widget_text(current),
)
)
current = parent
return WidgetPath("", nodes, is_map_canvas)
[docs]
def find_widget(self) -> QWidget | None:
"""Walk the path from the top-level window to find the target widget."""
window = self._find_window()
if window is None:
return None
current = window
for node in self.nodes:
child = self._find_child(current, node)
if child is None:
return None
current = child
return current
def _find_window(self) -> QWidget | None:
for widget in QApplication.topLevelWidgets():
if widget.isVisible() and widget.windowTitle() == self.window_title:
return widget
return None
@staticmethod
def _find_child(parent: QWidget, node: WidgetPathNode) -> QWidget | None:
same_class_children = [
child
for child in parent.findChildren(QWidget)
if child.__class__.__name__ == node.widget_class
and child.parentWidget() is parent
]
# First try exact match by sibling index and text
if node.sibling_index < len(same_class_children):
candidate = same_class_children[node.sibling_index]
if node.matches(candidate):
return candidate
# Fallback: find by text match among same-class siblings
if node.text:
for child in same_class_children:
if node.matches(child):
return child
# Last resort: return by index alone
if node.sibling_index < len(same_class_children):
return same_class_children[node.sibling_index]
return None
[docs]
class MacroEvent(Protocol):
"""Single macro event for Macros."""
ms_since_last_event: int
[docs]
def perform_event_action(self, schedule_next: Callable[[], None]) -> None:
"""Perform macro event action (e.g., moving mouse, clicking widget)."""
...
[docs]
@dataclass(frozen=True)
class Position:
"""Screen position represented as local and global coordinate pairs."""
local_position: tuple[int, int]
global_position: tuple[int, int]
[docs]
@staticmethod
def from_event(event: QMouseEvent | QWheelEvent) -> "Position":
"""Create a Position from a Qt mouse or wheel event."""
position = utils.event_pos(event)
global_position = utils.event_global_pos(event)
return Position(
(position.x(), position.y()),
(global_position.x(), global_position.y()),
)
[docs]
@staticmethod
def from_points(local_point: QPoint, global_point: QPoint) -> "Position":
"""Create a Position from local and global QPoint objects."""
return Position(
(local_point.x(), local_point.y()),
(global_point.x(), global_point.y()),
)
[docs]
@staticmethod
def interpolate(
positions: list["Position"], number_of_positions: int
) -> list["Position"]:
"""Reduce *positions* to *number_of_positions* by linear interpolation."""
if len(positions) <= number_of_positions:
return positions
def _interpolate(points: list[tuple[int, int]]) -> list[tuple[int, int]]:
x, y = list(zip(*points, strict=False))
line = QgsLineString(x, y)
distance = line.length() / (number_of_positions - 1)
interpolated_points = [
line.interpolatePoint(point_distance)
for point_distance in [
distance * i for i in range(1, number_of_positions - 1)
]
]
interpolated_points = [
(int(point.x()), int(point.y())) for point in interpolated_points
]
return [points[0], *interpolated_points, points[-1]]
local_positions = _interpolate([pos.local_position for pos in positions])
global_positions = _interpolate([pos.global_position for pos in positions])
return [
Position(local_point, global_point)
for local_point, global_point in zip(
local_positions, global_positions, strict=False
)
]
@property
def local_point(self) -> QPoint:
"""Return the local position as a QPoint."""
return QPoint(*self.local_position)
@property
def global_point(self) -> QPoint:
"""Return the global position as a QPoint."""
return QPoint(*self.global_position)
[docs]
def widget_corrected_position(self, widget: QWidget) -> "Position":
"""Return a new Position corrected for the widget's current screen location."""
return Position.from_points(
self.local_point, widget.mapToGlobal(self.local_point)
)
default_position = Position((0, 0), (0, 0))
[docs]
@dataclass
class BaseMacroEvent(ABC):
"""Base class for all macro events."""
widget_spec: WidgetSpec
ms_since_last_event: int = 0
widget_path: WidgetPath | None = None
[docs]
@staticmethod
def move_cursor(position: tuple[int, int] | QPoint) -> None:
"""Move the mouse cursor to the given screen position."""
if not isinstance(position, QPoint):
position = QPoint(*position)
QCursor.setPos(position)
QgsApplication.processEvents()
def _use_coordinate_lookup(self) -> bool:
"""Check if this event targets the map canvas and should use coordinates."""
return self.widget_path is not None and self.widget_path.is_map_canvas
[docs]
def get_widget(self, position: Position) -> QWidget:
"""Resolve the target widget at *position*.
Fall back to a spec-based search if the widget at *position* does not match.
"""
# Try widget path lookup first (unless targeting map canvas)
if self.widget_path is not None and not self._use_coordinate_lookup():
widget = self.widget_path.find_widget()
if widget is not None:
widget.setFocus()
return widget
LOGGER.debug(
"Widget path lookup failed, falling back to position-based lookup"
)
# Fallback: position-based lookup
global_point = position.global_point
widget = QApplication.widgetAt(global_point)
if not widget:
raise WidgetNotFoundError(
self.widget_spec.widget_class, self.widget_spec.text
)
if not self.widget_spec.matches(widget):
# Sometimes dialogs might appear in a slightly different position
widget = self.widget_spec.get_suitable_widget(global_point, widget.parent())
widget.setFocus()
return widget
[docs]
def get_widget_and_corrected_position(
self, position: Position
) -> tuple[QWidget, Position]:
"""Return the target widget and a screen-corrected position."""
widget = self.get_widget(position)
corrected_position = position.widget_corrected_position(widget)
return widget, corrected_position
[docs]
@abstractmethod
def perform_event_action(self, schedule_next: Callable[[], None]) -> None:
"""Execute the event action and call *schedule_next* when done."""
...
def __eq__(self, other: object) -> bool: # noqa: D105
if not isinstance(other, BaseMacroEvent):
return NotImplemented
return self.widget_spec == other.widget_spec
[docs]
@dataclass
class MacroKeyEvent(BaseMacroEvent):
"""Keyboard press or release event."""
key: int = 0
is_release: bool = False
modifiers: int = enum_value(Qt.KeyboardModifier.NoModifier)
[docs]
def perform_event_action(self, schedule_next: Callable[[], None]) -> None:
"""Replay the key press or release on the currently focused widget."""
widget = QApplication.focusWidget()
QgsApplication.processEvents()
# TODO: shift is not working
schedule_next()
if not self.is_release:
QTest.keyPress(
widget, Qt.Key(self.key), Qt.KeyboardModifiers(self.modifiers)
)
else:
QTest.keyRelease(
widget, Qt.Key(self.key), Qt.KeyboardModifiers(self.modifiers)
)
def __eq__(self, other: object) -> bool: # noqa: D105
if not isinstance(other, MacroKeyEvent):
return NotImplemented
return super().__eq__(other) and (
self.ms_since_last_event == other.ms_since_last_event
and self.key == other.key
and self.is_release == other.is_release
and self.modifiers == other.modifiers
)
[docs]
@dataclass
class MacroMouseMoveEvent(BaseMacroEvent):
"""Mouse movement event containing a sequence of positions."""
positions: list[Position] = field(default_factory=list)
buttons: int = enum_value(Qt.MouseButton.NoButton)
modifiers: int = enum_value(Qt.KeyboardModifier.NoModifier)
[docs]
def add_position(self, position: Position) -> None:
"""Append a position, ignoring duplicates of the last position."""
if self.positions and position == self.positions[-1]:
return
self.positions.append(position)
[docs]
def perform_event_action(self, schedule_next: Callable[[], None]) -> None:
"""Replay the mouse movement along the recorded positions."""
if self.buttons != enum_value(Qt.MouseButton.NoButton):
self.perform_event_action_with_event()
schedule_next()
return
if not self.positions:
return
widget = self.get_widget(self.positions[0])
for position in self.positions:
self.move_cursor(position.widget_corrected_position(widget).global_point)
schedule_next()
return
[docs]
def perform_event_action_with_event(self) -> None:
"""Replay movement by posting QMouseEvent objects (when buttons are held)."""
if not self.positions:
return
widget = self.get_widget(self.positions[0])
for position in self.positions:
corrected_position = position.widget_corrected_position(widget)
# Create and send mouse move events
event = QMouseEvent(
QEvent.Type.MouseMove,
corrected_position.local_point,
corrected_position.global_point,
Qt.MouseButton.NoButton,
Qt.MouseButtons(self.buttons),
Qt.KeyboardModifiers(self.modifiers),
)
QApplication.postEvent(widget, event)
QApplication.processEvents()
[docs]
def interpolate_positions(self, number_of_positions: int) -> None:
"""Interpolate the positions to a given number of positions."""
self.positions = Position.interpolate(self.positions, number_of_positions)
def __eq__(self, other: object) -> bool: # noqa: D105
if not isinstance(other, MacroMouseMoveEvent):
return NotImplemented
return super().__eq__(other) and (
self.positions == other.positions
and self.buttons == other.buttons
and self.modifiers == other.modifiers
)
def __repr__(self) -> str: # noqa: D105
if len(self.positions) == 1:
positions = self.positions
else:
positions = [self.positions[0], self.positions[-1]]
return (
f"MacroMouseMoveEvent(ms_since_last_event={self.ms_since_last_event}, "
f"widget_spec={self.widget_spec}, "
f"modifiers={self.modifiers}, "
f"buttons={self.buttons}, "
f"positions={positions})"
)
[docs]
@dataclass
class MacroMouseEvent(BaseMacroEvent):
"""Mouse button press or release event."""
position: Position = default_position
is_release: bool = False
button: int = enum_value(Qt.MouseButton.LeftButton)
modifiers: int = enum_value(Qt.KeyboardModifier.NoModifier)
[docs]
def perform_event_action(self, schedule_next: Callable[[], None]) -> None:
"""Replay the mouse press or release at the recorded position."""
widget, corrected_position = self.get_widget_and_corrected_position(
self.position
)
self.move_cursor(corrected_position.global_point)
schedule_next()
if not self.is_release:
# Ensure the widget under the mouse cursor is focused
QTest.mousePress(
widget,
Qt.MouseButton(self.button),
Qt.KeyboardModifiers(self.modifiers),
corrected_position.local_point,
)
else:
QTest.mouseRelease(
widget,
Qt.MouseButton(self.button),
Qt.KeyboardModifiers(self.modifiers),
corrected_position.local_point,
)
def __eq__(self, other: object) -> bool: # noqa: D105
if not isinstance(other, MacroMouseEvent):
return NotImplemented
return super().__eq__(other) and (
self.position == other.position
and self.button == other.button
and self.modifiers == other.modifiers
and self.is_release == other.is_release
)
[docs]
@dataclass
class MacroWheelEvent(BaseMacroEvent):
"""Mouse wheel scroll event."""
position: Position = default_position
delta: int = 0
phase: int = 0
inverted: bool = False
source: int = 0
[docs]
def perform_event_action(self, schedule_next: Callable[[], None]) -> None:
"""Replay the wheel scroll at the recorded position."""
widget, corrected_position = self.get_widget_and_corrected_position(
self.position
)
self.move_cursor(corrected_position.global_point)
schedule_next()
event = QWheelEvent(
corrected_position.local_point,
corrected_position.global_point,
QPoint(0, self.delta),
QPoint(0, self.delta),
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
self.phase,
self.inverted,
self.source,
)
QApplication.postEvent(widget, event)
QApplication.processEvents()
def __eq__(self, other: object) -> bool: # noqa: D105
if not isinstance(other, MacroWheelEvent):
return NotImplemented
return super().__eq__(other) and (
self.position == other.position
and self.delta == other.delta
and self.phase == other.phase
and self.inverted == other.inverted
and self.source == other.source
)
[docs]
@dataclass
class MacroMouseDoubleClickEvent(BaseMacroEvent):
"""Mouse double-click event."""
position: Position = default_position
button: int = enum_value(Qt.MouseButton.LeftButton)
modifiers: int = enum_value(Qt.KeyboardModifier.NoModifier)
[docs]
def perform_event_action(self, schedule_next: Callable[[], None]) -> None:
"""Replay the double-click at the recorded position."""
widget, corrected_position = self.get_widget_and_corrected_position(
self.position
)
self.move_cursor(corrected_position.global_point)
schedule_next()
QTest.mouseDClick(
widget,
Qt.MouseButton(self.button),
Qt.KeyboardModifiers(self.modifiers),
corrected_position.local_point,
)
def __eq__(self, other: object) -> bool: # noqa: D105
if not isinstance(other, MacroMouseDoubleClickEvent):
return NotImplemented
return super().__eq__(other) and (
self.position == other.position
and self.button == other.button
and self.modifiers == other.modifiers
)
[docs]
@dataclass
class Macro:
"""A recorded sequence of user interaction events.
Example::
import json
from pathlib import Path
from qgis_macros.macro import Macro
# Load macros from a JSON file
with Path("macros.json").open() as f:
data = json.load(f)
macros = [Macro.deserialize(d) for d in data]
# Serialize macros back to JSON
serialized = [m.serialize() for m in macros]
"""
events: list[MacroEvent]
name: str | None = None
speed: float = 1.0
qgis_version: int = Qgis.versionInt()
[docs]
def serialize(self) -> dict:
"""Serialize the macro to a JSON-compatible dict."""
events: list[dict] = []
data = {
"name": self.name,
"speed": self.speed,
"events": events,
"qgis_version": self.qgis_version,
}
for event in self.events:
class_name = event.__class__.__name__
serialized_event = dataclasses.asdict(event) # type: ignore[call-overload]
serialized_event["type"] = class_name
events.append(serialized_event)
return data
[docs]
@classmethod
def deserialize(cls, data: dict) -> "Macro":
"""Construct a Macro from a dict previously produced by :meth:`serialize`."""
events = []
for event_data in data["events"]:
class_name = event_data.pop("type")
widget_spec_ = event_data.pop("widget_spec")
widget_spec = WidgetSpec(widget_spec_["widget_class"], widget_spec_["text"])
event_data["widget_spec"] = widget_spec
if "position" in event_data:
position_ = event_data.pop("position")
position = Position(
position_["local_position"], position_["global_position"]
)
event_data["position"] = position
if "positions" in event_data:
positions_ = event_data.pop("positions")
positions = [
Position(position_["local_position"], position_["global_position"])
for position_ in positions_
]
event_data["positions"] = positions
widget_path_data = event_data.pop("widget_path", None)
if widget_path_data is not None:
nodes = [
WidgetPathNode(
widget_class=node["widget_class"],
sibling_index=node["sibling_index"],
text=node.get("text", ""),
)
for node in widget_path_data["nodes"]
]
event_data["widget_path"] = WidgetPath(
window_title=widget_path_data["window_title"],
nodes=nodes,
is_map_canvas=widget_path_data.get("is_map_canvas", False),
)
event_cls = globals()[class_name]
event = event_cls(**event_data)
events.append(event)
return cls(events, data["name"], data["speed"], data["qgis_version"])