diff --git a/docs/source/index.rst b/docs/source/index.rst index 4d2735b..62c69e2 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -11,6 +11,14 @@ with a piece of hardware into **standard inputs, outputs, and actions**. It then minimising the need for boilerplate code. Puzzlepiece allows the user to bring diverse controls into a single, consolidated application, and automate their interaction or experiment using a unified API, either through a built-in script language, or Interactive Python. +You can install puzzlepiece using pip:: + + pip install puzzlepiece + +Some examples are located on GitHub: `how to construct an app `_ +or `how to code a Piece `_ +(a single GUI module, representing an experimental device or task). The full source code is available at https://github.com/jdranczewski/puzzlepiece . + .. toctree:: :maxdepth: 2 :caption: Contents: diff --git a/puzzlepiece/param.py b/puzzlepiece/param.py index f804e25..5be7c07 100644 --- a/puzzlepiece/param.py +++ b/puzzlepiece/param.py @@ -130,6 +130,7 @@ def set_value(self, value=None): # If the setter did not return a value, see if there is a getter if self._getter is not None: new_value = self._getter() + new_value = self._type(new_value) else: # Otherwise the new value is just the value we're setting new_value = value @@ -396,7 +397,9 @@ def _click_handler(self, _): try: if self._connected_click_handler is not None: self._connected_click_handler() - self.set_value() + if self._setter is not None: + # If there's a setter, we need to explicitly call set_value here + self.set_value() except Exception as e: # Flip back the checkbox if the click resulted in an error self.input.setChecked(not(self.input.isChecked())) @@ -534,6 +537,45 @@ def _input_set_value(self, value): def _input_get_value(self): """:meta private:""" return self.input.currentText() + +class ParamProgress(BaseParam): + """ + A param with a progress bar. See the :func:`~puzzlepiece.param.progress` decorator below + for how to use this in your Piece. + """ + _type = float + + def _make_input(self, value=None, connect=None): + """:meta private:""" + input = QtWidgets.QProgressBar() + input.setMinimum(0) + input.setMaximum(1000) + if value is not None: + input.setValue(value) + return input, True + + def _input_set_value(self, value): + """:meta private:""" + if value < 0: + self.input.setMaximum(0) + else: + self.input.setMaximum(1000) + self.input.setValue(int(value*1000)) + + def _input_get_value(self): + """:meta private:""" + return self.input.value() + + def iter(self, iterable): + if hasattr(iterable, '__len__'): + length = len(iterable) + else: + length = -1 + + for i, value in enumerate(iterable): + self.set_value(i/length) + yield value + self.set_value(1) def wrap_setter(piece, setter): """ @@ -758,4 +800,19 @@ def decorator(values): values = values(piece) piece.params[name] = ParamDropdown(name, value, values, None, None, visible) return piece.params[name] + return decorator + +def progress(piece, name, visible=True): + """ + A decorator generator for registering a :class:`~puzzlepiece.param.ParamProgress` in a Piece's + :func:`~puzzlepiece.piece.Piece.define_params` method with a given **getter**. + + This will display the current progress value on a scale of 0 to 1 with no option to edit it. + + See :func:`~puzzlepiece.param.base_param` for more details. + """ + def decorator(getter): + wrapper = wrap_getter(piece, getter) + piece.params[name] = ParamProgress(name, None, setter=None, getter=wrapper, visible=visible) + return piece.params[name] return decorator \ No newline at end of file diff --git a/puzzlepiece/piece.py b/puzzlepiece/piece.py index d953979..e330d3c 100644 --- a/puzzlepiece/piece.py +++ b/puzzlepiece/piece.py @@ -7,11 +7,12 @@ class Piece(QtWidgets.QGroupBox): A single `Piece` object is an unit of automation - an object that is meant to represent a single physical instrument (like a laser) or a particular functionality (like a plotter or a parameter scan). - Pieces can be assembled into a :class:`~puzzlepiece.puzzle.Puzzle`. + Pieces can be assembled into a :class:`~puzzlepiece.puzzle.Puzzle` using the Puzzle's + :func:`~puzzlepiece.puzzle.Puzzle.add_piece` method. :param puzzle: The parent :class:`~puzzlepiece.puzzle.Puzzle`. - :param custom_horizontal: A bool flat, the custom layout is displayed to the right of the main controls - if True. + :param custom_horizontal: A bool, the custom layout is displayed to the right of the main controls + if True. """ def __init__(self, puzzle, custom_horizontal=False, *args, **kwargs): super().__init__() @@ -72,7 +73,7 @@ def action_layout(self, wrap=2): """ Genereates a `QGridLayout` for the actions. Override to set a different wrapping. - :param wrap: the number of columns the actions are displayed in . + :param wrap: the number of columns the actions are displayed in. :rtype: QtWidgets.QGridLayout """ layout = QtWidgets.QGridLayout() @@ -118,6 +119,35 @@ def setup(self): """ pass + def open_popup(self, popup): + """ + Open a popup window for this Piece. A popup is a :class:`puzzlepiece.piece.Popup` + object, which is like a Piece but floats in a separate window attached to the main + :class:`~puzzlepiece.puzzle.Puzzle`. This can be used for handling additional tasks + that you don't want to clutter the main Piece. See :class:`puzzlepiece.piece.Popup` + for details on implementing a Popup. + + :param popup: a :class:`puzzlepiece.piece.Popup` _class_ to instantiate + :rtype: puzzlepiece.piece.Popup + """ + # Instantiate the popup + if isinstance(popup, type): + popup = popup(self, self.puzzle) + popup.setStyleSheet("QGroupBox {border:0;}") + + # Make a dialog window for the popup to live in + dialog = _QDialog(self, popup) + layout = QtWidgets.QVBoxLayout() + dialog.setLayout(layout) + layout.addWidget(popup) + + # Display the dialog + dialog.show() + dialog.raise_() + dialog.activateWindow() + + return popup + def call_stop(self): """ This method is called by the parent Puzzle when a global stop is called. @@ -130,8 +160,8 @@ def call_stop(self): def handle_close(self, event): """ - Only called if the :class:`~puzzlepiece.puzzle.Puzzle` debug flag is False. - Override to disconnect hardware etc when the main window closes. + Only called if the :class:`~puzzlepiece.puzzle.Puzzle` :attr:`~puzzlepiece.puzzle.Puzzle.debug` + flag is False. Override to disconnect hardware etc when the main window closes. """ pass @@ -218,4 +248,65 @@ def wrapped_main(self, *args, **kwargs): return True else: ensure_function(self) - return ensure_decorator \ No newline at end of file + return ensure_decorator + +class _QDialog(QtWidgets.QDialog): + """ + A variant of the QDialog specifically for popups, handles closing them + with a custom function. + """ + def __init__(self, parent, popup, *args, **kwargs): + self.popup = popup + super().__init__(parent, *args, **kwargs) + + def closeEvent(self, event): + self.popup.handle_close() + super().closeEvent(event) + +class Popup(Piece): + """ + A Popup is similar to a Piece, but floats in a separate window attached to the main + :class:`~puzzlepiece.puzzle.Puzzle`. This can be used for handling additional tasks + that you don't want to clutter the main Piece. For example you can have a camera + Piece which can open a Popup to set the camera's region of interest with an interactive + plot window. + + A Popup can be created and displayed by calling :func:`puzzlepiece.piece.Piece.open_popup`. + + A Popup is attached to a specific Piece and knows it through its + :attr:`~puzzlepiece.piece.Popup.parent_piece` attribute, but it can also access other + Pieces through the Puzzle, which it knows through its :attr:`~puzzlepiece.piece.Piece.puzzle` + attribute. + + A Popup can have params, actions, and custom layouts just like a normal Piece, and are created by + overriding :func:`~puzzlepiece.piece.Piece.define_params`, :func:`~puzzlepiece.piece.Piece.define_actions`, + and :func:`~puzzlepiece.piece.Piece.custom_layout` like for a Piece. + + :param puzzle: The parent :class:`~puzzlepiece.puzzle.Puzzle`. + :param parent_piece: The parent :class:`~puzzlepiece.piece.Piece`. + :param custom_horizontal: A bool, the custom layout is displayed to the right of the main controls + if True. + """ + def __init__(self, parent_piece, puzzle, custom_horizontal=False, *args, **kwargs): + self._parent_piece = parent_piece + super().__init__(puzzle, custom_horizontal, *args, **kwargs) + self.layout.setContentsMargins(0,0,0,0) + + @property + def parent_piece(self): + """ + A reference to this Popup's parent :class:`~puzzlepiece.piece.Piece`, + the one that created it through :func:`puzzlepiece.piece.Piece.open_popup`. + """ + return self._parent_piece + + def handle_close(self): + """ + Called when the Popup is closed. Override to perform actions when the user + closes this Popup - for example delete related plot elements. + + In contrast to :func:`puzzlepiece.piece.Piece.handle_close`, this is called even + if the :class:`~puzzlepiece.puzzle.Puzzle` :attr:`~puzzlepiece.puzzle.Puzzle.debug` + flag is True. + """ + pass \ No newline at end of file diff --git a/puzzlepiece/puzzle.py b/puzzlepiece/puzzle.py index d69ab87..803abd1 100644 --- a/puzzlepiece/puzzle.py +++ b/puzzlepiece/puzzle.py @@ -37,7 +37,39 @@ def __init__(self, app, name, debug=True, *args, **kwargs): self.wrapper_layout.addLayout(self._button_layout(), 1, 0) - sys.excepthook = self._excepthook + try: + # If this doesn't raise a NameError, we're in IPython + shell = get_ipython() + # _orig_sys_module_state stores the original IPKernelApp excepthook, + # irrespective of possible modifications in other cells + self._old_excepthook = shell._orig_sys_module_state['excepthook'] + + # The following hack allows us to handle exceptions through the Puzzle in IPython. + # Normally when a cell is executed in an IPython InteractiveShell, + # sys.excepthook is overwritten with shell.excepthook, and then restored + # to sys.excepthook after the cell run finishes. Any changes we make to + # sys.excepthook in here directly will thus be overwritten as soon as the + # cell that defines the Puzzle finishes running. + + # Instead, we schedule set_excepthook on a QTimer, meaning that it will + # execute in the Qt loop rather than in a cell, so it can modify + # sys.excepthook without risk of the changes being immediately overwritten, + + # For bonus points, we could set _old_excepthook to shell.excepthook, + # which would result in all tracebacks appearing in the Notebook rather + # than the console, but I think that is not desireable. + def set_excepthook(): + sys.excepthook = self._excepthook + QtCore.QTimer.singleShot(0, set_excepthook) + except NameError: + # In normal Python (not IPython) this is comparatively easy. + # We use the original system hook here instead of sys.excepthook + # to avoid unexpected behaviour if multiple things try to override + # the hook in various ways. + # If you need to implement custom exception handling, please assign + # a value to your Puzzle's ``custom_excepthook`` method. + self._old_excepthook = sys.__excepthook__ + sys.excepthook = self._excepthook @property def pieces(self): @@ -130,7 +162,10 @@ def run_worker(self, worker): self._threadpool.start(worker) def _excepthook(self, exctype, value, traceback): - sys.__excepthook__(exctype, value, traceback) + self._old_excepthook(exctype, value, traceback) + + # Stop any threads that may be running + self._shutdown_threads.emit() # Only do custom exception handling in the main thread, otherwise the messagebox # or other such things are likely to break things. @@ -338,6 +373,9 @@ def closeEvent(self, event): if not self.debug: for piece_name in self.pieces: self.pieces[piece_name].handle_close(event) + + # Reinstate the original excepthook + sys.excepthook = self._old_excepthook super().closeEvent(event) diff --git a/pyproject.toml b/pyproject.toml index 2b88b21..9e52f91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "puzzlepiece" -version = "0.8.0" +version = "0.9.0" authors = [ { name="Jakub Dranczewski", email="jakub.dranczewski@gmail.com" }, ]