# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
"""
.. Macro panel (see parent package :mod:`cdl.core.gui.panel`)
"""
# pylint: disable=invalid-name # Allows short reference names like x, y, ...
from __future__ import annotations
import os.path as osp
import re
from typing import TYPE_CHECKING
from guidata.config import CONF
from guidata.configtools import get_font, get_icon
from guidata.qthelpers import add_actions, create_action
from guidata.widgets.console.shell import PythonShellWidget
from guidata.widgets.dockable import DockableWidgetMixin
from qtpy import QtCore as QC
from qtpy import QtWidgets as QW
from qtpy.compat import getopenfilename, getsavefilename
from cdl.config import Conf, _
from cdl.core.gui.macroeditor import Macro
from cdl.core.gui.panel.base import AbstractPanel
from cdl.env import execenv
from cdl.utils.qthelpers import (
create_menu_button,
qt_try_loadsave_file,
save_restore_stds,
)
if TYPE_CHECKING:
from guidata.widgets.codeeditor import CodeEditor
from cdl.core.io.native import NativeH5Reader, NativeH5Writer
[docs]
class MacroTabs(QW.QTabWidget):
"""Macro tabwidget
Args:
parent (QWidget): Parent widget
"""
SIG_CONTEXT_MENU = QC.Signal(QC.QPoint)
SIG_RENAME = QC.Signal(int)
SIG_REMOVE = QC.Signal(int)
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setTabsClosable(True)
self.setMovable(True)
self.tabBarDoubleClicked.connect(self.__rename)
self.tabCloseRequested.connect(self.__remove)
self.__titles: list[str] = []
[docs]
def clear(self) -> None:
"""Override Qt method"""
super().clear()
self.__titles.clear()
def __rename(self, index: int) -> None:
"""Rename tab
Args:
index: Index of the tab
"""
self.SIG_RENAME.emit(index + 1)
def __remove(self, index: int) -> None:
"""Remove tab
Args:
index: Index of the tab
"""
self.SIG_REMOVE.emit(index + 1)
def __update_tab_titles(self) -> None:
"""Update tab titles"""
for number, title in enumerate(self.__titles, 1):
self.setTabText(number - 1, f"{number:02d}: {title}")
[docs]
def add_tab(self, macro: Macro) -> int:
"""Add tab
Args:
macro: Macro object
Returns:
int: Number of the tab (starting at 1)
"""
self.__titles.append(macro.title)
index = self.addTab(macro.editor, "")
self.__update_tab_titles()
return index + 1 # Numbering starts at 1
[docs]
def remove_tab(self, number: int) -> None:
"""Remove tab
Args:
number: Number of the tab (starting at 1)
"""
self.removeTab(number - 1)
self.__titles.pop(number - 1)
self.__update_tab_titles()
[docs]
def set_current_number(self, number: int) -> None:
"""Set current tab number
Args:
number: Number of the tab (starting at 1)
"""
self.setCurrentIndex(number - 1)
[docs]
def get_current_number(self) -> int:
"""Return current tab number
Returns:
int: Number of the tab (starting at 1)
"""
return self.currentIndex() + 1
[docs]
def set_tab_title(self, number: int, name: str) -> None:
"""Set tab title
Args:
number: Number of the tab (starting at 1)
name: Macro name
"""
self.__titles[number - 1] = name
self.__update_tab_titles()
[docs]
class MacroPanel(AbstractPanel, DockableWidgetMixin):
"""Macro Panel widget
Args:
parent (QWidget): Parent widget
"""
LOCATION = QC.Qt.RightDockWidgetArea
PANEL_STR = _("Macro panel")
H5_PREFIX = "DataLab_Mac"
SIG_OBJECT_MODIFIED = QC.Signal()
FILE_FILTERS = f"{_('Python files')} (*.py)"
def __init__(self, parent: QW.QWidget | None = None) -> None:
super().__init__(parent)
self.setWindowTitle(self.PANEL_STR)
self.setWindowIcon(get_icon("libre-gui-cogs.svg"))
self.setOrientation(QC.Qt.Vertical)
self.context_menu = QW.QMenu()
self.tabwidget_tb = QW.QToolBar(self)
self.tabwidget_tb.setOrientation(QC.Qt.Vertical)
self.console = PythonShellWidget(self, read_only=True)
self.console.setMaximumBlockCount(5000)
font = get_font(CONF, "console")
font.setPointSize(10)
self.console.set_font(font)
self.console.write(_("-***- Macro Console -***-"), prompt=True)
self.tabwidget = MacroTabs(self)
self.tabwidget.SIG_RENAME.connect(self.rename_macro)
self.tabwidget.SIG_REMOVE.connect(self.remove_macro)
self.tabwidget.currentChanged.connect(self.__update_actions)
tabwidget_with_tb = QW.QWidget(self)
tabwidget_with_tb.setLayout(QW.QHBoxLayout())
tabwidget_with_tb.layout().addWidget(self.tabwidget_tb)
tabwidget_with_tb.layout().addWidget(self.tabwidget)
# Put console in a groupbox to have a title
console_groupbox = QW.QGroupBox(_("Console"), self)
console_groupbox.setLayout(QW.QHBoxLayout())
console_groupbox.layout().addWidget(self.console)
# Put console groupbox in a frame to have a nice margin
console_frame = QW.QFrame(self)
console_frame.setLayout(QW.QHBoxLayout())
console_frame.layout().addWidget(console_groupbox)
for widget in (tabwidget_with_tb, console_frame):
self.addWidget(widget)
self.setStretchFactor(0, 2)
self.setStretchFactor(1, 1)
self.run_action = None
self.stop_action = None
self.obj_actions: list[QW.QAction] = [] # Object-dependent actions
self.__macros: list[Macro] = []
self.setup_actions()
[docs]
def update_color_mode(self) -> None:
"""Update color mode according to the current theme"""
self.console.update_color_mode()
for macro in self.__macros:
macro.editor.update_color_mode()
# ------AbstractPanel interface-----------------------------------------------------
# pylint: disable=unused-argument
[docs]
def get_serializable_name(self, obj: Macro) -> str:
"""Return serializable name of object"""
title = re.sub("[^-a-zA-Z0-9_.() ]+", "", obj.title.replace("/", "_"))
name = f"{obj.PREFIX}{(self.__macros.index(obj)+1):03d}: {title}"
return name
[docs]
def serialize_to_hdf5(self, writer: NativeH5Writer) -> None:
"""Serialize whole panel to a HDF5 file
Args:
writer: HDF5 writer
"""
with writer.group(self.H5_PREFIX):
for obj in self.__macros:
self.serialize_object_to_hdf5(obj, writer)
[docs]
def deserialize_from_hdf5(self, reader: NativeH5Reader) -> None:
"""Deserialize whole panel from a HDF5 file
Args:
reader: HDF5 reader
"""
with reader.group(self.H5_PREFIX):
for name in reader.h5.get(self.H5_PREFIX, []):
# Contrary to signal or image panels, macros are not stored
# in a group but directly in the root of the HDF5 file
obj = self.deserialize_object_from_hdf5(reader, name)
self.add_object(obj)
def __len__(self) -> int:
"""Return number of objects"""
return len(self.__macros)
def __getitem__(self, nb: int) -> Macro:
"""Return object from its number (1 to N)"""
return self.__macros[nb - 1]
def __iter__(self):
"""Iterate over objects"""
return iter(self.__macros)
[docs]
def create_object(self) -> Macro:
"""Create object.
Returns:
Macro object
"""
macro = Macro(self.console)
macro.objectNameChanged.connect(self.macro_name_changed)
macro.STARTED.connect(
lambda orig_macro=macro: self.macro_state_changed(orig_macro, True)
)
macro.FINISHED.connect(
lambda orig_macro=macro: self.macro_state_changed(orig_macro, False)
)
macro.MODIFIED.connect(self.macro_contents_changed)
return macro
[docs]
def add_object(self, obj: Macro) -> None:
"""Add object.
Args:
obj: Macro object
"""
self.__macros.append(obj)
number = self.tabwidget.add_tab(obj)
self.SIG_OBJECT_ADDED.emit()
self.tabwidget.set_current_number(number)
[docs]
def remove_all_objects(self) -> None:
"""Remove all objects"""
self.tabwidget.clear()
self.__macros.clear()
super().remove_all_objects()
# ---- Macro panel API -------------------------------------------------------------
[docs]
def setup_actions(self) -> None:
"""Setup macro menu actions"""
self.run_action = create_action(
self,
_("Run macro"),
icon=get_icon("libre-camera-flash-on.svg"),
triggered=self.run_macro,
shortcut="Ctrl+F5",
)
self.stop_action = create_action(
self,
_("Stop macro"),
icon=get_icon("libre-camera-flash-off.svg"),
triggered=self.stop_macro,
shortcut="Shift+F5",
)
self.stop_action.setDisabled(True)
add_act = create_action(
self,
_("New macro"),
icon=get_icon("libre-gui-add.svg"),
triggered=self.add_macro,
)
ren_act = create_action(
self,
_("Rename macro"),
icon=get_icon("libre-gui-pencil.svg"),
triggered=self.rename_macro,
)
imp_act = create_action(
self,
_("Import macro from file"),
icon=get_icon("fileopen_py.svg"),
triggered=self.import_macro_from_file,
)
exp_act = create_action(
self,
_("Export macro to file"),
icon=get_icon("filesave_py.svg"),
triggered=self.export_macro_to_file,
)
rem_act = create_action(
self,
_("Remove macro"),
icon=get_icon("libre-gui-action-delete.svg"),
triggered=self.remove_macro,
)
self.obj_actions += [
self.run_action,
self.stop_action,
ren_act,
exp_act,
rem_act,
]
self.tabwidget.SIG_CONTEXT_MENU.connect(self.context_menu.popup)
tabwidget_corner = QW.QToolBar(_("Macro editor toolbar"), self)
self.context_menu.aboutToShow.connect(self.__update_actions)
tabwidget_menu_btn = create_menu_button(self, self.context_menu)
tabwidget_corner.addWidget(tabwidget_menu_btn)
self.tabwidget.setCornerWidget(tabwidget_corner)
main_actions = [
self.run_action,
self.stop_action,
None,
add_act,
ren_act,
imp_act,
exp_act,
]
add_actions(tabwidget_corner, [self.run_action, self.stop_action])
add_actions(self.tabwidget_tb, main_actions)
add_actions(self.context_menu, main_actions + [None, rem_act])
self.__update_actions()
def __update_actions(self) -> None:
"""Update actions"""
not_empty = self.tabwidget.count() > 0
for action in self.obj_actions:
action.setEnabled(not_empty)
if not_empty:
macro = self.get_macro()
if macro is not None:
macro: Macro
self.macro_state_changed(macro, macro.is_running())
[docs]
def get_macro(self, number_or_title: int | str | None = None) -> Macro | None:
"""Return macro at number (if number is None, return current macro)
Args:
number: Number of the macro (starting at 1) or title of the macro.
Defaults to None (current macro).
Returns:
Macro object or None (if not found)
"""
if number_or_title is None:
number_or_title = self.tabwidget.get_current_number()
if isinstance(number_or_title, str):
return self.get_macro(self.get_number_from_title(number_or_title))
for macro in self.__macros:
if self.tabwidget.get_widget(number_or_title) is macro.editor:
return macro
return None
[docs]
def get_number_from_title(self, title: str) -> int | None:
"""Return macro number from title
Args:
title: Title of the macro
Returns:
Number of the macro (starting at 1) or None (if not found)
"""
for number in range(1, self.tabwidget.count() + 1):
if self.tabwidget.tabText(number - 1).endswith(title):
return number
return None
[docs]
def get_number_from_macro(self, macro: Macro) -> int | None:
"""Return macro number from macro object
Args:
macro: Macro object
Returns:
Number of the macro (starting at 1) or None (if not found)
"""
for number in range(1, self.tabwidget.count() + 1):
if self.tabwidget.get_widget(number) is macro.editor:
return number
return None
[docs]
def get_macro_titles(self) -> list[str]:
"""Return list of macro titles"""
return [macro.title for macro in self.__macros]
[docs]
def macro_contents_changed(self) -> None:
"""One of the macro contents has changed"""
self.SIG_OBJECT_MODIFIED.emit()
[docs]
def run_macro(self, number_or_title: int | str | None = None) -> None:
"""Run current macro
Args:
number: Number of the macro (starting at 1). Defaults to None (run
current macro, or does nothing if there is no macro).
"""
macro = self.get_macro(number_or_title)
if macro is not None:
macro: Macro
macro.run()
[docs]
def stop_macro(self, number_or_title: int | str | None = None) -> None:
"""Stop current macro
Args:
number: Number of the macro (starting at 1). Defaults to None (run
current macro, or does nothing if there is no macro).
"""
macro = self.get_macro(number_or_title)
if macro is not None:
macro: Macro
macro.kill()
[docs]
def macro_state_changed(self, orig_macro: Macro, state: bool) -> None:
"""Macro state has changed (True: started, False: stopped)
Args:
orig_macro: Macro object
state: State of the macro
"""
macro = self.get_macro()
if macro is orig_macro:
self.run_action.setEnabled(not state)
self.stop_action.setEnabled(state)
[docs]
def add_macro(self) -> Macro:
"""Add macro, optionally with name
Returns:
Macro object
"""
macro = self.create_object()
self.add_object(macro)
if not macro.title:
self.rename_macro()
return macro
[docs]
def macro_name_changed(self, name: str) -> None:
"""Macro name has been changed
Args:
name: New name of the macro
"""
number = self.get_number_from_macro(self.sender())
if number is not None:
self.tabwidget.set_tab_title(number, name)
[docs]
def rename_macro(self, number: int | None = None, title: str | None = None) -> None:
"""Rename macro
Args:
number: Number of the macro (starting at 1). Defaults to None.
title: Title of the macro. Defaults to None.
"""
macro = self.get_macro(number)
assert isinstance(macro, Macro)
if title is None:
title, valid = QW.QInputDialog.getText(
self,
_("Rename"),
_("New title:"),
QW.QLineEdit.Normal,
macro.title,
)
title = title if valid else None
if title:
macro.title = title
if number is not None:
self.tabwidget.set_current_number(number)
[docs]
def export_macro_to_file(
self, number_or_title: int | str | None = None, filename: str | None = None
) -> None:
"""Export macro to file
Args:
number_or_title: Number of the macro (starting at 1) or title of the macro.
Defaults to None.
filename: Filename. Defaults to None.
Raises:
ValueError: If title is not found
"""
macro = self.get_macro(number_or_title)
assert isinstance(macro, Macro)
if filename is None:
basedir = Conf.main.base_dir.get()
with save_restore_stds():
filename, _filt = getsavefilename(
self, _("Save as"), basedir, self.FILE_FILTERS
)
if filename:
with qt_try_loadsave_file(self.parent(), filename, "save"):
Conf.main.base_dir.set(filename)
macro.title = osp.basename(filename)
macro.to_file(filename)
[docs]
def import_macro_from_file(self, filename: str | None = None) -> int:
"""Import macro from file
Args:
filename: Filename. Defaults to None.
Returns:
Number of the macro (starting at 1)
"""
if filename is None:
basedir = Conf.main.base_dir.get()
with save_restore_stds():
filename, _filt = getopenfilename(
self, _("Open"), basedir, self.FILE_FILTERS
)
if filename:
with qt_try_loadsave_file(self.parent(), filename, "load"):
Conf.main.base_dir.set(filename)
macro = self.add_macro()
macro.from_file(filename)
return self.get_number_from_macro(macro)
return -1
[docs]
def remove_macro(self, number_or_title: int | str | None = None) -> None:
"""Remove macro
Args:
number_or_title: Number of the macro (starting at 1) or title of the macro.
Defaults to None.
"""
if number_or_title is None:
number_or_title = self.tabwidget.get_current_number()
if isinstance(number_or_title, str):
number_or_title = self.get_number_from_title(number_or_title)
txt = "<br>".join(
[
_(
"When closed, the macro is <u>permanently destroyed</u>, "
"unless it has been exported first."
),
"",
_("Do you want to continue?"),
]
)
btns = QW.QMessageBox.StandardButton.Yes | QW.QMessageBox.StandardButton.No
if execenv.unattended:
choice = QW.QMessageBox.StandardButton.Yes
else:
choice = QW.QMessageBox.warning(self, self.windowTitle(), txt, btns)
if choice == QW.QMessageBox.StandardButton.Yes:
self.tabwidget.remove_tab(number_or_title)
self.__macros.pop(number_or_title - 1)
self.SIG_OBJECT_REMOVED.emit()