Source code for qgis_macros.macro_recorder

#  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/>.
"""Event filter-based macro recorder that captures user interactions."""

from typing import cast

from qgis.PyQt.QtCore import QElapsedTimer, QEvent, QObject
from qgis.PyQt.QtGui import QKeyEvent, QMouseEvent, QWheelEvent
from qgis.PyQt.QtWidgets import QApplication, QWidget

from qgis_macros.macro import (
    LOGGER,
    Macro,
    MacroEvent,
    MacroKeyEvent,
    MacroMouseDoubleClickEvent,
    MacroMouseEvent,
    MacroMouseMoveEvent,
    MacroWheelEvent,
    Position,
    WidgetPath,
    WidgetSpec,
)
from qgis_macros.settings import Settings
from qgis_macros.utils import enum_value


[docs] class MacroRecorder(QObject): """Manages recording of user actions like mouse and keyboard events. Tracks and collects events during a recording session, allowing filtered or unfiltered playback of these events for automation. """ def __init__( self, filter_out_mouse_movements: bool = True, # noqa: FBT001, FBT002 ) -> None: """Initialize the recorder. Args: filter_out_mouse_movements: If True, leading/trailing mouse move events are trimmed from the recording. """ super().__init__(None) self._recorded_events: list[MacroEvent] = [] self._timer = QElapsedTimer() self.last_record_time = 0 # Tracks the last timestamp self._recording = False self._filter_out_mouse_movements = filter_out_mouse_movements self._widgets_to_filter_events_out: list[QWidget] = []
[docs] def add_widget_to_filter_events_out(self, widget: QWidget) -> None: """Add a widget to filter events out from the recorded events.""" self._widgets_to_filter_events_out.append(widget)
[docs] def is_recording(self) -> bool: """Check if the recorder is currently recording.""" return self._recording
[docs] def start_recording(self) -> None: """Start recording user actions.""" self._recorded_events.clear() self._recording = True self._timer.restart() QApplication.instance().installEventFilter(self)
[docs] def stop_recording(self) -> Macro: """Stop recording user actions. :return: New Macro object with recorded events """ if not self._recording: return Macro([]) self._recording = False QApplication.instance().removeEventFilter(self) events = ( self._get_filtered_events() if self._filter_out_mouse_movements else self._recorded_events ) self._interpolate_mouse_move_events(events) macro = Macro(events) LOGGER.debug("Recorded macro %s", macro) return macro
[docs] def eventFilter(self, obj: QObject, event: QEvent) -> bool: # noqa: N802 """Event filter to record keyboard and mouse events.""" if not self._recording or not isinstance(obj, QWidget): return super().eventFilter(obj, event) widget = cast("QWidget", obj) elapsed = self._timer.elapsed() ms_since_last_event = elapsed - self.last_record_time self.last_record_time = elapsed if ( isinstance(event, QMouseEvent) and widget in self._widgets_to_filter_events_out ): return super().eventFilter(obj, event) if event.type() in [QEvent.Type.KeyPress, QEvent.Type.KeyRelease]: self._record_key_event(event, widget, ms_since_last_event) elif event.type() in [ QEvent.Type.MouseButtonPress, QEvent.Type.MouseButtonRelease, ]: self._record_mouse_button_event(event, widget, ms_since_last_event) elif event.type() == QEvent.Type.MouseButtonDblClick: self._record_mouse_button_double_click_event( event, widget, ms_since_last_event ) elif event.type() == QEvent.Type.MouseMove: self._record_mouse_move_event(event, widget) elif event.type() == QEvent.Type.Wheel: self._record_mouse_wheel_event(event, widget) return super().eventFilter(obj, event)
def _get_filtered_events(self) -> list[MacroEvent]: filtered_events: list[MacroEvent] = [] if not self._recorded_events: return [] # Take just the last mouse position for the first element first_element = self._recorded_events[0] if isinstance(first_element, MacroMouseMoveEvent): first_index = 1 filtered_events.append( MacroMouseMoveEvent( widget_spec=first_element.widget_spec, ms_since_last_event=0, positions=[first_element.positions[-1]], widget_path=first_element.widget_path, ) ) else: first_index = 0 last_index = len(self._recorded_events) - 1 if isinstance(self._recorded_events[last_index], MacroMouseMoveEvent): last_index -= 1 for event in self._recorded_events[first_index : last_index + 1]: if isinstance(event, MacroMouseMoveEvent) and not self._is_map_canvas_event( event ): # For non-map-canvas moves, keep only the last position filtered_events.append( MacroMouseMoveEvent( widget_spec=event.widget_spec, ms_since_last_event=event.ms_since_last_event, positions=[event.positions[-1]], buttons=event.buttons, modifiers=event.modifiers, widget_path=event.widget_path, ) ) else: filtered_events.append(event) return filtered_events @staticmethod def _is_map_canvas_event(event: MacroMouseMoveEvent) -> bool: return event.widget_path is not None and event.widget_path.is_map_canvas def _interpolate_mouse_move_events(self, events: list[MacroEvent]) -> None: """Interpolate mouse move events.""" point_count = Settings.move_event_interpolation_count.get() for event in events: if ( isinstance(event, MacroMouseMoveEvent) and len(event.positions) > point_count ): event.interpolate_positions(point_count) def _record_key_event( self, event: QKeyEvent, widget: QWidget, elapsed: int ) -> None: """Record key press or release events.""" macro_event = MacroKeyEvent( ms_since_last_event=elapsed, key=event.key(), is_release=event.type() == QEvent.Type.KeyRelease, modifiers=enum_value(event.modifiers()), widget_spec=WidgetSpec.create(widget), widget_path=WidgetPath.create(widget), ) # Do not add if the last mouse button event was the same for i in range(len(self._recorded_events) - 1, -1, -1): if isinstance((previous_event := self._recorded_events[i]), MacroKeyEvent): if ( previous_event.key == macro_event.key and previous_event.is_release == macro_event.is_release ): return break self._recorded_events.append(macro_event) def _record_mouse_button_event( self, event: QMouseEvent, widget: QWidget, elapsed: int ) -> None: """Record mouse button press or release events.""" macro_event = MacroMouseEvent( ms_since_last_event=elapsed, position=Position.from_event(event), is_release=event.type() == QEvent.Type.MouseButtonRelease, button=enum_value(event.button()), modifiers=enum_value(event.modifiers()), widget_spec=WidgetSpec.create(widget), widget_path=WidgetPath.create(widget), ) # Do not add if the last mouse button event was the same for i in range(len(self._recorded_events) - 1, -1, -1): if isinstance( (previous_event := self._recorded_events[i]), MacroMouseEvent ): if ( previous_event.button == macro_event.button and previous_event.is_release == macro_event.is_release ): return break self._recorded_events.append(macro_event) def _record_mouse_button_double_click_event( self, event: QMouseEvent, widget: QWidget, elapsed: int ) -> None: """Record mouse double click events.""" self._recorded_events.append( MacroMouseDoubleClickEvent( ms_since_last_event=elapsed, position=Position.from_event(event), button=enum_value(event.button()), modifiers=enum_value(event.modifiers()), widget_spec=WidgetSpec.create(widget), widget_path=WidgetPath.create(widget), ) ) def _record_mouse_move_event(self, event: QMouseEvent, widget: QWidget) -> None: """Record mouse movement events.""" current_position = Position.from_event(event) last_event = self._recorded_events[-1] if self._recorded_events else None if isinstance(last_event, MacroMouseMoveEvent): last_event.add_position(current_position) else: self._recorded_events.append( MacroMouseMoveEvent( widget_spec=WidgetSpec.create(widget), ms_since_last_event=0, positions=[current_position], buttons=enum_value(event.buttons()), modifiers=enum_value(event.modifiers()), widget_path=WidgetPath.create(widget), ) ) def _record_mouse_wheel_event(self, event: QWheelEvent, widget: QWidget) -> None: """Record mouse wheel events.""" self._recorded_events.append( MacroWheelEvent( WidgetSpec.create(widget), ms_since_last_event=0, position=Position.from_event(event), delta=event.angleDelta().y(), phase=event.phase(), source=event.source(), inverted=event.inverted(), widget_path=WidgetPath.create(widget), ) )