import inspect
from pathlib import Path
from typing import Any, Union, TYPE_CHECKING, Dict, List, Optional, Iterable
import qt_themes
from qt_themes import Theme
from qtpy.QtCore import QObject, QLocale
from qtpy import QtCore, QtWidgets
from pymodaq_data.h5modules.data_saving import DataToExportSaver
from pymodaq_gui.h5modules.saving import H5Saver
from pymodaq_gui.managers.runner_thread_manager import WorkerThreadManager
from pymodaq_gui.managers.h5manager import FileStatus, H5Manager, FileAction
from pymodaq_utils.config import GlobalConfig as Config
from pymodaq_utils.enums import StrEnum
from pymodaq_utils.logger import set_logger, get_module_name
from pymodaq_utils.config import get_set_path, get_set_local_dir
from pymodaq_utils.warnings import deprecation_msg
from pymodaq_utils.help import get_help_text
from pymodaq_gui.utils.dock import DockArea, Dock
from pymodaq_gui.managers.action_manager import ActionManager
from pymodaq_gui.managers.parameter_manager import ParameterManager
from pymodaq_gui.parameter import ParameterTree
from pymodaq_gui.utils.splash import get_splash_sc
from pymodaq_gui.utils.styling import ThemeColor
logger = set_logger(get_module_name(__file__))
config = Config()
[docs]
class WorkFlowActions(StrEnum):
"""Names of the actions created by :meth:`CustomApp.setup_workflow_actions`"""
START = 'start'
STOP = 'stop'
PAUSE = 'pause'
LOG = 'log'
[docs]
class CustomApp(QObject, ActionManager, ParameterManager):
"""Base Class to ease the implementation of User Interfaces
Inherits the MixIns ActionManager and ParameterManager classes. You have to subclass some methods and make
concrete implementation of a given number of methods:
* setup_docks_and_widgets: to code the widget layout of your Application using Docks (and the DockArea)
or other widgets
* setup_menus_and_toolbars: to create the menus and the toolbar associated with actions (see setup_actions)
* setup_actions: add actions (see :class:`pymodaq_gui.managers.action_manager.ActionManager`) or widgets and optionally add them
to toolbar and menu
* connect_things: to connect signals and slots. Either from actions
(:meth:`pymodaq_gui.managers.action_manager.ActionManager.connect_action`)
or direct signal connection
Other methods to reimplement, related to Parameter management
* value_changed: non mandatory, see :class:`pymodaq.utils.managers.parameter_manager.ParameterManager`
* child_added: non mandatory, see :class:`pymodaq.utils.managers.parameter_manager.ParameterManager`
* param_deleted: non mandatory, see :class:`pymodaq.utils.managers.parameter_manager.ParameterManager`
Depending on the object type, the mainwindow and dockarea attributes may be None
if parent is:
* None or QWidget, the attributes will be
* parent = QWidget
* maindow = None
* dockarea = None
* DockArea, the attributes will be
* parent = DockArea
* maindow = QMainWindow
* dockarea = DockArea
* QMainWindow, the attributes will be
* parent = QMainWindow
* maindow = QMainWindow
* dockarea = None
Attributes
----------
title: str
Get/set the app title
parent: QWidget, QMainWindow or DockArea
mainwindow: QMainWindow
the parent QMainWindow
dockarea: DockArea
The underlying DockArea (as central widget of the QMainWindow)
menubar: QMenuBar
The QMainWindow menubar
statusbar: QStatusBar
The QMainWindow statusbar
splash_sc: QtWidgets.QSplashScreen
A splash screen to be used to display information
get_theme: method
Returns the current QApplication theme, see qt_themes package
Parameters
----------
parent: None, QWidget, QMainWindow or DockArea
tree: ParameterTree
an optional Custom ParameterTree
title: str
The title of the Application instance
toolbar: QTtWidgets.QToolbar
a toolbar from another parent application
create_app_toolbar: bool
If True (default) will create a default toolbar with the name of the application as reference and title
add_toolbar_break: bool
If True, will add a break in the QToolbarArea before adding the toolbar
create_app_menu: bool
If True (default is False) will create a default menu in the menubar with the name of the
application as reference and title
See Also
--------
:class:`pymodaq.utils.managers.action_manager.ActionManager`,
:class:`pymodaq.utils.managers.parameter_manager.ParameterManager`,
"""
log_signal = QtCore.Signal(str)
show_h5file_statusbar_widgets = False
show_workflow_actions = False
h5_base_group_name = 'AppData' # rename that in your app/extension to give a meaningful name to your base group
params: List[Any] = [] # the parameters (list of dicts) shown in the settings tree
parent: Any # DockArea, QMainWindow or QWidget, as given to __init__
mainwindow: QtWidgets.QMainWindow # None if the parent is not a DockArea or a QMainWindow
dockarea: DockArea # None if the parent is not a DockArea or a QMainWindow
def __init__(self, parent: Union[DockArea, QtWidgets.QMainWindow, QtWidgets.QWidget, None] = None,
tree: Optional[ParameterTree] = None, title: Optional[str] = None,
toolbar: Optional[QtWidgets.QToolBar] = None,
create_app_toolbar: bool = True, add_toolbar_break: bool = True,
create_app_menu: bool = False,
h5_actions_not: Iterable[FileAction] = (FileAction.CLOSE_FILE, FileAction.OPEN_FILE)) -> None:
QObject.__init__(self)
ActionManager.__init__(self)
ParameterManager.__init__(self, tree=tree)
self._splash_sc: Optional[QtWidgets.QSplashScreen] = None
if not (isinstance(parent, (DockArea, QtWidgets.QMainWindow, QtWidgets.QWidget))):
parent = QtWidgets.QWidget()
self.parent = parent
if isinstance(parent, DockArea):
self.dockarea = parent
self.mainwindow = parent.parent()
elif isinstance(parent, QtWidgets.QMainWindow):
self.dockarea = None # type: ignore[assignment]
self.mainwindow = parent
else:
self.dockarea = None # type: ignore[assignment]
self.mainwindow = None # type: ignore[assignment]
self._title: str = ''
self.title = title
# then call self.h5saver property
self.docks: Dict[str, Dock] = dict([])
self._menubar: QtWidgets.QMenuBar = None # type: ignore[assignment] # None without a main window
self._statusbar: QtWidgets.QStatusBar = None # type: ignore[assignment] # only used without a main window
if toolbar is not None:
create_app_toolbar = True # force the app toolbar to be the given one
if create_app_toolbar:
self.add_toolbar(self.__class__.__name__.lower(),
self.__class__.__name__,
self.mainwindow,
toolbar, # type: ignore[arg-type]
add_break=add_toolbar_break)
self.set_toolbar(toolbar) # type: ignore[arg-type]
if self.mainwindow is not None:
self.mainwindow.setWindowTitle(self.title)
self._menubar = self.mainwindow.menuBar() # type: ignore[assignment]
else:
parent.setWindowTitle(self.title)
self._statusbar = QtWidgets.QStatusBar()
self._status_message_label: QtWidgets.QLabel = None # type: ignore[assignment] # set by populate_status_bar
if create_app_menu:
self.add_menu(self.__class__.__name__.lower(),
self.__class__.__name__,
self.menubar if self.mainwindow is not None else None)
self._h5_manager = H5Manager(self, show_not=h5_actions_not)
self._worker_thread_manager = WorkerThreadManager(parent=self)
[docs]
def get_help_markdown(self) -> str:
"""Markdown text describing how to use this application, shown by the Help action
Read from the help.md (or <module>.help.md) next to the module defining the class, else the class docstring
"""
return get_help_text(self) or inspect.cleandoc(self.__class__.__doc__ or '')
@property
def thread_manager(self) -> WorkerThreadManager:
"""The manager of the worker threads of this app"""
return self._worker_thread_manager
@property
def h5_manager(self) -> H5Manager:
"""The manager of the h5 file and of its related actions and status widgets"""
return self._h5_manager
@property
def h5saver(self) -> H5Saver:
""" Convenience method to access the h5saver and for backcompatibility"""
return self.h5_manager.h5saver
[docs]
@classmethod
def get_local_folder(cls, user: bool = False) -> Path:
""" Create a local User or system wide folder to store things about this extension"""
return get_set_path(get_set_local_dir(user=user), cls.__name__)
@property
def menubar(self) -> QtWidgets.QMenuBar:
"""The menubar of the main window, None if there is no main window"""
return self._menubar
@property
def statusbar(self) -> QtWidgets.QStatusBar:
"""The status bar of the main window (or the one of this app if it has no main window)"""
return self.mainwindow.statusBar() if self.mainwindow is not None else self._statusbar # type: ignore[return-value]
[docs]
def populate_status_bar(self) -> None:
"""Generic method to populate the Status Bar
for customization, reimplement insert_custom_status_widgets method
"""
self._status_message_label = QtWidgets.QLabel('')
self.statusbar.addPermanentWidget(self._status_message_label)
self.insert_custom_status_widgets()
if self.show_h5file_statusbar_widgets:
self.h5_manager.insert_h5stuff_status()
[docs]
def set_permanent_status(self, status: str) -> None:
""" Display a permanent status message
Method populate_status_bar should have been called beforehand
"""
self._status_message_label.setText(status)
[docs]
def update_status(self, message: str, wait_time: Optional[int] = None) -> None:
"""Show the message in the status bar with a delay of wait_time ms.
"""
if self.statusbar is not None:
if wait_time is None:
wait_time = config('gui', 'message_status_persistence')
self.statusbar.showMessage(message, wait_time)
@property
def splash_sc(self) -> QtWidgets.QSplashScreen:
"""The splash screen to display information, created at the first access"""
if not hasattr(self, "_splash_sc") or self._splash_sc is None:
self._splash_sc = get_splash_sc()
return self._splash_sc
@property
def title(self) -> str:
"""The title of the app (the name of the class if it has been set to None)"""
return self._title
@title.setter
def title(self, title: Optional[str]) -> None:
self._title = title if title is not None else self.__class__.__name__
if self.mainwindow is not None:
self.mainwindow.setWindowTitle(self._title)
[docs]
@staticmethod
def get_theme(name: Optional[str] = None) -> Theme:
"""Returns the theme named *name*, or the current one if None (see the qt_themes package)"""
return qt_themes.get_theme(name)
def _overrides(self, name: str) -> bool:
"""True if the class of this instance (or one of its parents below CustomApp) redefines the method ``name``
of CustomApp, used to call the deprecated hooks only for the apps that still define them"""
return getattr(type(self), name) is not getattr(CustomApp, name)
[docs]
def setup_ui(self) -> None:
"""Build the UI: docks and widgets, menus, toolbars, actions and connections (calls the hooks in order)"""
self.setup_docks_and_widgets()
self.setup_menus_and_toolbars(self.menubar) # see ActionManager MixIn class
if self.show_workflow_actions:
self.setup_workflow_actions()
self.setup_actions() # see ActionManager MixIn class
self.connect_things()
self.do_things_after_ui_setup()
self.apply_size_hint()
[docs]
def quit_fun(self) -> bool | None:
"""Method to be reimplemented in order to define a custom quit function
"""
if len(self.thread_manager.worker_threads) > 0:
self.thread_manager.exit_worker_threads()
if self.mainwindow is not None:
self.mainwindow.close()
self.disconnect_tree()
self.delete_actions()
return True
[docs]
def delete_actions(self) -> None:
"""Schedule the deletion of the QActions of this app, including the ones of its H5Manager
See :meth:`ActionManager.delete_actions`
.. versionadded:: 5.3.9
"""
super().delete_actions()
self._h5_manager.delete_actions()
self._h5_manager.deleteLater() # unparented: the slots connected to its signals would keep the app alive
[docs]
def do_things_after_ui_setup(self) -> None:
""" Method to be reimplemented in order to do things after the UI setup
"""
pass
[docs]
def apply_size_hint(self) -> None:
"""Resize the main window (or the parent widget) to the size hint of the app"""
if self.mainwindow is not None:
self.mainwindow.resize(self._size_hint)
else:
self.parent.resize(self._size_hint)
@property
def _size_hint(self) -> QtCore.QSize:
""" property telling the optimal size for your application UI
To be reimplemented
"""
return QtCore.QSize(1200, 800)
[docs]
def setup_docks(self) -> None:
""" deprecated, see setup_docks_and_widgets
"""
pass
[docs]
def setup_actions(self) -> None:
"""Method where to create actions.
To be reimplemented
Examples
--------
>>> self.add_action('grab', 'Grab', 'camera', "Grab from camera", checkable=True, menu='file_menu')
>>> self.add_action('load', 'Load', 'Open', "Load target file (.h5, .png, .jpg) or data from camera", checkable=False)
>>> self.add_action('save', 'Save' 'SaveAs', "Save current data", checkable=False)
>>>self.affect_to('load', 'file_menu')
>>>self.affect_to('save', 'file_menu')
"""
pass
[docs]
def setup_workflow_actions(self) -> None:
"""Create the start, stop, pause and log actions (in the actions menu), see :class:`WorkFlowActions`"""
if 'actions' not in self.menus:
self.add_menu('actions', 'Actions', parent_menu=self.menubar)
self.add_action(WorkFlowActions.START, 'Start Workflow', 'motion_play',
"Start the workflow",
menu='actions', icon_color=ThemeColor.GREEN)
self.add_action(WorkFlowActions.STOP, 'Stop Workflow', 'stop_circle', "Stop the workflow",
menu='actions', icon_color=ThemeColor.RED)
self.add_action(WorkFlowActions.PAUSE, 'Pause Workflow', 'pause_circle', "Pause/resume the workflow",
checkable=True, menu='actions',
icon_checked_color=ThemeColor.ORANGE)
self.toolbar.addSeparator()
self.add_action(WorkFlowActions.LOG, 'Do Logging', 'home_storage',
tip='Log all data generated within the workflow',
menu='actions',
icon_checked_color=ThemeColor.GREEN,
icon_color=ThemeColor.RED,
checkable=True,
checked=True)
self.toolbar.addSeparator()
[docs]
def enable_workflow_actions(self,
enable: bool = True,
excepted: Iterable[str | WorkFlowActions] = (),
opposite: Iterable[str | WorkFlowActions] = (),
other_actions: Iterable[str | WorkFlowActions] = ()) -> None:
""" Enable/Disable workflow actions (start, stop, pause) + other specified ones
if an action is specified in excepted, nothing is done on it
if an action is specified in opposite, the opposite boolean is applied to its enabled status
Everytime this function is called the Pause action is unchecked
"""
if not isinstance(excepted, Iterable):
excepted = [excepted]
if not isinstance(other_actions, Iterable):
other_actions = [other_actions]
if not isinstance(opposite, Iterable):
opposite = [opposite]
for action in [WorkFlowActions(value) for value in WorkFlowActions.values()] + list(other_actions):
if self.has_action(action) and action not in excepted:
if action in opposite:
self.set_action_enabled(action, not enable)
else:
self.set_action_enabled(action, enable)
self.set_action_checked(WorkFlowActions.PAUSE, False)
[docs]
def connect_things(self) -> None:
"""Connect actions and/or other widgets signal to methods
To be reimplemented
"""
pass
@property
def module_and_data_saver(self) -> DataToExportSaver:
"""A DataToExportSaver using the h5saver of the app"""
return DataToExportSaver(self.h5_manager.h5saver)