# 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 panel UI with recording, playback, and file I/O controls."""
import json
from pathlib import Path
from typing import Any
from qgis.core import QgsApplication
from qgis.gui import QgsDevToolWidget, QgsDevToolWidgetFactory
from qgis.PyQt.QtGui import QIcon
from qgis.PyQt.QtWidgets import (
QFileDialog,
QHeaderView,
QTableView,
QToolButton,
QWidget,
)
from qgis_macros.exceptions import MacroPluginError
from qgis_macros.macro import Macro
from qgis_macros.macro_player import (
MacroPlaybackReport,
MacroPlaybackStatus,
MacroPlayer,
)
from qgis_macros.macro_recorder import MacroRecorder
from qgis_macros.settings import Settings
from qgis_plugin_tools.tools.decorations import log_if_fails
from qgis_plugin_tools.tools.i18n import tr
from qgis_plugin_tools.tools.messages import MsgBar
from qgis_plugin_tools.tools.resources import load_ui_from_file, resources_path
from macro_plugin.ui.macro_model import MacroTableModel
from macro_plugin.ui.settings_dialog import SettingsDialog
MACRO_GROUP = "Macro"
UI_CLASS: QWidget = load_ui_from_file(
str(Path(__file__).parent.joinpath("macro_panel.ui"))
)
[docs]
class MacroPanel(UI_CLASS, QgsDevToolWidget): # type: ignore
"""A dev tool widget for macro recording, playing, and deletion.
Provides a table view to display macros and buttons to record, play, and delete
macros.
"""
button_record: QToolButton
button_play: QToolButton
button_delete: QToolButton
button_open: QToolButton
button_save: QToolButton
button_settings: QToolButton
table_view: QTableView
def __init__(
self,
macro_recorder: MacroRecorder,
macro_player: MacroPlayer,
parent: QWidget | None = None,
) -> None:
"""Initialize the panel.
:param macro_recorder: Instance of MacroRecorder to handle macro
recording interactions.
:param macro_player: Instance of MacroPlayer to handle macro
playback functionality.
:param parent: Optional parent QWidget for UI hierarchy.
"""
super().__init__(parent)
self.setupUi(self)
self._recorder = macro_recorder
self._recorder.add_widget_to_filter_events_out(self)
self._recorder.add_widget_to_filter_events_out(self.button_record)
self._player = macro_player
self._player.playback_ended.connect(self._macro_playback_ended)
self._last_played_macro_name: str | None = None
self._model = MacroTableModel()
self._configure_table()
self._configure_buttons()
self._update_ui_state()
def _configure_table(self) -> None:
"""Set up the table view with appropriate settings."""
self.table_view.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Stretch
)
self.table_view.setModel(self._model)
self.table_view.selectionModel().selectionChanged.connect(self._update_ui_state)
def _configure_buttons(self) -> None:
"""Configure buttons with icons, tooltips, and connect them to actions."""
button_config = {
self.button_record: (
self._toggle_recording,
"/mActionRecord.svg",
),
self.button_play: (
self._play_macro,
"/mActionPlay.svg",
),
self.button_delete: (
self._delete_macros,
"/mActionDeleteSelected.svg",
),
self.button_open: (
self._load_macros_from_file,
"/mActionFileOpen.svg",
),
self.button_save: (
self._save_macros_to_file,
"/mActionFileSave.svg",
),
self.button_settings: (
self._open_settings,
"/console/iconSettingsConsole.svg",
),
}
for button, (action, icon) in button_config.items():
button.setAutoRaise(True)
button.setIcon(QgsApplication.getThemeIcon(icon))
button.clicked.connect(action)
def _validate_macro_selection(self) -> bool:
"""Check if there are selected macros available for operations."""
return bool(self._model.macros and self.table_view.selectedIndexes())
def _generate_macro_name(self) -> str:
return f"macro_{len(self._model.macros) + 1}"
def _toggle_recording(self) -> None:
if not self._recorder.is_recording():
self._recorder.start_recording()
else:
macro = self._recorder.stop_recording()
macro.name = self._generate_macro_name()
self._model.add_macro(macro)
new_index = self._model.index(len(self._model.macros) - 1, 0)
self.table_view.setCurrentIndex(new_index)
self.table_view.edit(new_index)
self._update_ui_state()
def _play_macro(self) -> None:
if not self._validate_macro_selection():
return
macro = self._model.macros[self.table_view.selectedIndexes()[0].row()]
if Settings.profile_macros.get():
QgsApplication.profiler().start(
f"Macro: {macro.name}", Settings.profile_macro_group.get()
)
self._last_played_macro_name = macro.name
self._player.play(macro)
@log_if_fails
def _macro_playback_ended(self, macro_report: MacroPlaybackReport) -> None:
if Settings.profile_macros.get():
QgsApplication.profiler().end(Settings.profile_macro_group.get())
if macro_report.status == MacroPlaybackStatus.FAILURE:
raise macro_report.error or MacroPluginError(
tr("Playback ended with failure.")
)
MsgBar.info(
tr("Macro playback ended"),
tr(
"Macro '{}' playback ended successfully.",
self._last_played_macro_name,
),
)
def _delete_macros(self) -> None:
if not self._validate_macro_selection():
return
for index in reversed(self.table_view.selectedIndexes()):
self._model.remove_macro(index.row())
self._update_ui_state()
def _open_settings(self) -> None:
SettingsDialog().exec()
self._player.set_speed(Settings.speed.get())
self._update_ui_state()
def _load_macros_from_file(self) -> None:
default_path = Path(Settings.macro_save_path.get())
default_path.mkdir(parents=True, exist_ok=True)
file_path, _ = QFileDialog.getOpenFileName(
self,
tr("Load Macros"),
str(default_path),
tr("Profiler Files (*.json);;All Files (*)"),
)
if file_path:
with Path(file_path).open("r") as path:
data = json.load(path)
macros = [Macro.deserialize(macro_data) for macro_data in data]
self._model.reset_macros(macros)
def _save_macros_to_file(self) -> None:
default_path = Path(Settings.macro_save_path.get())
default_path.mkdir(parents=True, exist_ok=True)
file_path, _ = QFileDialog.getSaveFileName(
self,
tr("Save Macros"),
str(default_path),
tr("Profiler Files (*.json);;All Files (*)"),
)
if file_path:
path = Path(file_path)
if not path.suffix:
path = path.with_name(path.name + ".json")
serialized_macros = [macro.serialize() for macro in self._model.macros]
with path.open("w") as f:
json.dump(serialized_macros, f, indent=4)
MsgBar.info(
tr("Macros saved"),
tr("File saved to {}", str(path)),
success=True,
)
def _update_ui_state(self, *args: Any) -> None:
"""Update button enabled/checked states to reflect current status."""
self.button_record.setChecked(self._recorder.is_recording())
self.button_play.setEnabled(len(self.table_view.selectedIndexes()) == 1)
self.button_save.setEnabled(bool(self._model.macros))
self.button_delete.setEnabled(bool(self.table_view.selectedIndexes()))