Source code for cdl.core.gui.panel.macro

# 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()
[docs] def contextMenuEvent(self, event): # pylint: disable=C0103 """Override Qt method""" self.SIG_CONTEXT_MENU.emit(event.globalPos())
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 get_widget(self, number: int) -> CodeEditor: """Return macro editor widget at number Args: number: Number of the tab (starting at 1) Returns: Macro editor widget """ return self.widget(number - 1)
[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()