From c626b3d4934a9152626a57b464fd020c2a4cfbe1 Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Wed, 26 Jun 2024 12:31:06 +0100 Subject: [PATCH 01/19] First draft of _replace_piece This functionality would allow the user to reload a Piece during development --- puzzlepiece/puzzle.py | 47 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/puzzlepiece/puzzle.py b/puzzlepiece/puzzle.py index b21c602..8ffc7db 100644 --- a/puzzlepiece/puzzle.py +++ b/puzzlepiece/puzzle.py @@ -1,5 +1,7 @@ -from pyqtgraph.Qt import QtWidgets, QtCore from . import parse + +from pyqtgraph.Qt import QtWidgets, QtCore +import importlib import sys @@ -133,6 +135,24 @@ def add_piece(self, name, piece, row, column, rowspan=1, colspan=1): self.register_piece(name, piece) return piece + + def replace_piece(self, name, new_piece): + old_piece = self.pieces[name] + if isinstance(new_piece, type): + new_piece = new_piece(self) + + if old_piece in self._toplevel: + self.layout.replaceWidget(old_piece, new_piece, options=QtCore.Qt.FindChildOption.FindDirectChildrenOnly) + new_piece.setTitle(name) + else: + for widget in self._toplevel: + if isinstance(widget, Folder): + widget._replace_piece(name, old_piece, new_piece) + + self._pieces._replace_item(name, new_piece) + old_piece.handle_close(None) + old_piece.deleteLater() + def add_folder(self, row, column, rowspan=1, colspan=1): """ @@ -483,6 +503,22 @@ def handle_shortcut(self, event): """ self.currentWidget().handle_shortcut(event) + def _replace_piece(self, name, old_piece, new_piece): + if old_piece in self.pieces: + index = self.indexOf(old_piece) + self.insertTab(index, new_piece, name) + new_piece.folder = self + # No title or border displayed when Piece in Folder + new_piece.setTitle(None) + new_piece.setStyleSheet("QGroupBox {border:0;}") + + self.pieces.remove(old_piece) + self.pieces.append(new_piece) + else: + for widget in self.pieces: + if isinstance(widget, Grid): + widget._replace_piece(name, old_piece, new_piece) + class Grid(QtWidgets.QWidget): """ @@ -521,6 +557,12 @@ def add_piece(self, name, piece, row, column, rowspan=1, colspan=1): piece.folder = self return piece + + def _replace_piece(self, name, old_piece, new_piece): + if old_piece in self.pieces: + self.layout.replaceWidget(old_piece, new_piece) + new_piece.setTitle(name) + new_piece.folder = self def handle_shortcut(self, event): """ @@ -566,6 +608,9 @@ def __getitem__(self, key): "A Piece with id '{}' is required, but doesn't exist".format(key) ) return self._dict[key] + + def _replace_item(self, key, value): + self._dict[key] = value def __contains__(self, item): return item in self._dict From d36ee774a8cce3d0a70afc48adbfd2f940ffac99 Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Thu, 15 Aug 2024 15:54:46 +0100 Subject: [PATCH 02/19] child params initial implementation allows for creating a child param that sets and gets from the original param. Useful for exposing invisible params as additional settings in a Popup --- puzzlepiece/param.py | 44 +++++++++++++++++++++++++++++++++++++++++++ puzzlepiece/piece.py | 6 ++++++ puzzlepiece/puzzle.py | 6 ++++-- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/puzzlepiece/param.py b/puzzlepiece/param.py index 8ce37b2..3173458 100644 --- a/puzzlepiece/param.py +++ b/puzzlepiece/param.py @@ -47,6 +47,7 @@ def __init__( **kwargs, ): super().__init__(*args, **kwargs) + self._name = name self._setter = setter self._getter = getter self._value = None @@ -268,6 +269,33 @@ def _input_get_value(self): """ return self._type(self.input.text()) + def make_child_param(self, kwargs=None): + # Only make an explicit setter if this param has an explicit setter. + # The other case is handled via a Signal below, once the child + # param is created. + setter = None if self._setter is None else (lambda value: self.set_value(value)) + + # child params always have a getter, to make the direction of data flow clear. + def getter(): + return self.get_value() + + kwargs = kwargs or {} + + child = type(self)( + self._name, self._value, setter=setter, getter=getter, **kwargs + ) + + if self._setter is None: + # If no explicit setter, just set the parent param whenever the child updates + child.changed.connect(lambda: self.set_value(child.value)) + elif self._value is not None: + # When a param is created and has an explicit setter, it will be highlighted + # red to indicate the setter has not been called. Here we remove the highlight + # for the child if the parent's setter has been called already. + child.input.setStyleSheet("") + + return child + @property def type(self): """ @@ -351,6 +379,15 @@ def _input_get_value(self): """:meta private:""" return self.input.value() + def make_child_param(self, kwargs=None): + return super().make_child_param( + kwargs={ + "v_min": self._v_min, + "v_max": self._v_max, + "v_step": self._v_step, + } + ) + class ParamFloat(ParamInt): """ @@ -670,6 +707,13 @@ def _input_get_value(self): """:meta private:""" return self.input.currentText() + def make_child_param(self, kwargs=None): + return super().make_child_param( + kwargs={ + "values": self._values, + } + ) + class ParamProgress(BaseParam): """ diff --git a/puzzlepiece/piece.py b/puzzlepiece/piece.py index c3bd4ad..5d58a1e 100644 --- a/puzzlepiece/piece.py +++ b/puzzlepiece/piece.py @@ -321,6 +321,12 @@ def parent_piece(self): """ return self._parent_piece + def add_child_params(self, param_names): + for name in param_names: + self.params[name] = self.parent_piece.params[name].make_child_param() + + # TODO: A way to close the Popup from 'within' + def handle_close(self): """ Called when the Popup is closed. Override to perform actions when the user diff --git a/puzzlepiece/puzzle.py b/puzzlepiece/puzzle.py index b21c602..22e6a3b 100644 --- a/puzzlepiece/puzzle.py +++ b/puzzlepiece/puzzle.py @@ -18,7 +18,9 @@ class Puzzle(QtWidgets.QWidget): :type bottom_buttons: bool """ - def __init__(self, app=None, name="Puzzle", debug=True, bottom_buttons=True, *args, **kwargs): + def __init__( + self, app=None, name="Puzzle", debug=True, bottom_buttons=True, *args, **kwargs + ): super().__init__(*args, **kwargs) # Pieces can handle the debug flag as they wish self._debug = debug @@ -519,7 +521,7 @@ def add_piece(self, name, piece, row, column, rowspan=1, colspan=1): self.pieces.append(piece) self.puzzle.register_piece(name, piece) piece.folder = self - + return piece def handle_shortcut(self, event): From 98964ad38a39062d3cfef0d275da22404d29631c Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Thu, 15 Aug 2024 15:56:24 +0100 Subject: [PATCH 03/19] Only add action column to DataGrid if the Row has actions --- puzzlepiece/extras/datagrid.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/puzzlepiece/extras/datagrid.py b/puzzlepiece/extras/datagrid.py index b9fd27e..9ce0801 100644 --- a/puzzlepiece/extras/datagrid.py +++ b/puzzlepiece/extras/datagrid.py @@ -33,7 +33,10 @@ def __init__(self, row_class, puzzle=None): self.param_names = row_example.params.keys() self._tree = QtWidgets.QTreeWidget() - self._tree.setHeaderLabels(("ID", *row_example.params.keys(), "actions")) + if len(row_example.actions.keys()): + self._tree.setHeaderLabels(("ID", *row_example.params.keys(), "actions")) + else: + self._tree.setHeaderLabels(("ID", *row_example.params.keys())) layout = QtWidgets.QVBoxLayout() layout.addWidget(self._tree) @@ -169,7 +172,8 @@ def elevate(self): def _populate_item(self, tree, item): for i, key in enumerate(self.params): tree.setItemWidget(item, i + 1, self.params[key]) - tree.setItemWidget(item, i + 2, self._action_buttons()) + if len(self.actions.keys()): + tree.setItemWidget(item, i + 2, self._action_buttons()) def _action_buttons(self): widget = QtWidgets.QWidget() From 4c91ac742848c10b56ef365942eb9d5135fa88f3 Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Fri, 16 Aug 2024 12:11:15 +0100 Subject: [PATCH 04/19] Improvements to replace_piece --- puzzlepiece/puzzle.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/puzzlepiece/puzzle.py b/puzzlepiece/puzzle.py index 8ffc7db..4cac5fe 100644 --- a/puzzlepiece/puzzle.py +++ b/puzzlepiece/puzzle.py @@ -144,6 +144,8 @@ def replace_piece(self, name, new_piece): if old_piece in self._toplevel: self.layout.replaceWidget(old_piece, new_piece, options=QtCore.Qt.FindChildOption.FindDirectChildrenOnly) new_piece.setTitle(name) + self._toplevel.remove(old_piece) + self._toplevel.append(new_piece) else: for widget in self._toplevel: if isinstance(widget, Folder): @@ -151,7 +153,9 @@ def replace_piece(self, name, new_piece): self._pieces._replace_item(name, new_piece) old_piece.handle_close(None) - old_piece.deleteLater() + # old_piece.deleteLater() + old_piece.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True) + old_piece.close() def add_folder(self, row, column, rowspan=1, colspan=1): @@ -563,6 +567,8 @@ def _replace_piece(self, name, old_piece, new_piece): self.layout.replaceWidget(old_piece, new_piece) new_piece.setTitle(name) new_piece.folder = self + self.pieces.remove(old_piece) + self.pieces.append(new_piece) def handle_shortcut(self, event): """ From bcfc3416cd01c2c9828cd6c59905004226768eff Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Fri, 23 Aug 2024 10:52:14 +0100 Subject: [PATCH 05/19] Set dialog title --- puzzlepiece/piece.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/puzzlepiece/piece.py b/puzzlepiece/piece.py index 5d58a1e..d277939 100644 --- a/puzzlepiece/piece.py +++ b/puzzlepiece/piece.py @@ -125,7 +125,7 @@ def setup(self): """ pass - def open_popup(self, popup): + def open_popup(self, popup, name=None): """ 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 @@ -134,6 +134,7 @@ def open_popup(self, popup): for details on implementing a Popup. :param popup: a :class:`puzzlepiece.piece.Popup` _class_ to instantiate + :param name: text to show as the window title :rtype: puzzlepiece.piece.Popup """ # Instantiate the popup @@ -146,6 +147,7 @@ def open_popup(self, popup): layout = QtWidgets.QVBoxLayout() dialog.setLayout(layout) layout.addWidget(popup) + dialog.setWindowTitle(name or "Popup") # Display the dialog dialog.show() From 4f1ddb4e62df230c25825a7a738f4ee9db081ae0 Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Fri, 23 Aug 2024 10:52:51 +0100 Subject: [PATCH 06/19] child params inherit format and type --- puzzlepiece/param.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/puzzlepiece/param.py b/puzzlepiece/param.py index 3173458..351c800 100644 --- a/puzzlepiece/param.py +++ b/puzzlepiece/param.py @@ -282,7 +282,8 @@ def getter(): kwargs = kwargs or {} child = type(self)( - self._name, self._value, setter=setter, getter=getter, **kwargs + self._name, self._value, setter=setter, getter=getter, + format=self._format, _type=self._type, **kwargs ) if self._setter is None: From 9a236b629a970f7c0a883de535c6e85f017a16bc Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Fri, 23 Aug 2024 18:02:37 +0100 Subject: [PATCH 07/19] Custom row classes and select_row --- puzzlepiece/extras/datagrid.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/puzzlepiece/extras/datagrid.py b/puzzlepiece/extras/datagrid.py index 9ce0801..4c0dc7c 100644 --- a/puzzlepiece/extras/datagrid.py +++ b/puzzlepiece/extras/datagrid.py @@ -56,15 +56,19 @@ def values(self): """ return [{key: x[key].value for key in x.params} for x in self.rows] - def add_row(self, **kwargs): + def add_row(self, row_class=None, **kwargs): """ Add a Row with default param values. + :param row_class: row class to use, if not specified uses the one provided + when the DataGrid was created. The class provided here should have the same + params and actions as the original one! :param kwargs: keyword arguments matching param names can be passed to set param values in the new row """ item = QtWidgets.QTreeWidgetItem(self._tree, (str(len(self.rows)),)) - row = self._row_class(self, self.puzzle) + row_class = row_class or self._row_class + row = row_class(self, self.puzzle) row._populate_item(self._tree, item) self.rows.append(row) self._items.append(item) @@ -91,6 +95,9 @@ def remove_row(self, id): self._items[i].setText(0, str(i)) self.rows_changed.emit() + def select_row(self, id): + self._tree.setCurrentItem(self._items[id]) + def get_index(self, row): """ Get the current index of a given :class:`~puzzlepiece.extras.datagrid.Row` object. From d5652497f2e27ffe6e85566984d2a216cf80043b Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Tue, 10 Sep 2024 11:22:07 +0100 Subject: [PATCH 08/19] Datagrid incrementals Related to the canvas project. parent_piece info, invisible param support, open_popup from Row --- puzzlepiece/extras/datagrid.py | 37 +++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/puzzlepiece/extras/datagrid.py b/puzzlepiece/extras/datagrid.py index 4c0dc7c..aa99bca 100644 --- a/puzzlepiece/extras/datagrid.py +++ b/puzzlepiece/extras/datagrid.py @@ -24,10 +24,11 @@ class DataGrid(QtWidgets.QWidget): #: A Qt signal emitted when any data in the DataGrid changes (including when rows are added/removed). data_changed = QtCore.Signal() - def __init__(self, row_class, puzzle=None): + def __init__(self, row_class, puzzle=None, parent_piece=None): super().__init__() #: Reference to the parent :class:`~puzzlepiece.puzzle.Puzzle`. self.puzzle = puzzle or pzp.puzzle.PretendPuzzle() + self.parent_piece = parent_piece self._row_class = row_class row_example = row_class(self.puzzle) self.param_names = row_example.params.keys() @@ -74,7 +75,7 @@ def add_row(self, row_class=None, **kwargs): self._items.append(item) for key in kwargs: row.params[key].set_value(kwargs[key]) - for param_name in self.param_names: + for param_name in row.params: if param_name in self._slots: for slot in self._slots[param_name]: row.params[param_name].changed.connect(slot) @@ -168,6 +169,35 @@ def define_actions(self): """ pass + def open_popup(self, popup, name=None): + """ + Open a popup window for this Row. + See :func:`puzzlepiece.piece.Piece.open_popup`. + + :param popup: a :class:`puzzlepiece.piece.Popup` _class_ to instantiate + :param name: text to show as the window title + :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 = pzp.piece._QDialog(self.parent, popup) + layout = QtWidgets.QVBoxLayout() + dialog.setLayout(layout) + layout.addWidget(popup) + dialog.setWindowTitle(name or "Popup") + + # Display the dialog + dialog.show() + dialog.raise_() + dialog.activateWindow() + self.puzzle._close_popups.connect(dialog.accept) + + return popup + def elevate(self): """ For compatibility with the Piece's API. @@ -177,7 +207,8 @@ def elevate(self): pass def _populate_item(self, tree, item): - for i, key in enumerate(self.params): + visible_params = [x for x in self.params if self.params[x].visible] + for i, key in enumerate(visible_params): tree.setItemWidget(item, i + 1, self.params[key]) if len(self.actions.keys()): tree.setItemWidget(item, i + 2, self._action_buttons()) From f2f5000375b34c51919ef1357fb8921bbfa1250b Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Fri, 13 Sep 2024 22:13:52 +0100 Subject: [PATCH 09/19] Reduce reliance on stylesheets These seem to fail in IPython sometimes for unknown reasons. Using Palettes always works. No way to fully remove a GroupBox border without stylesheets, so two methods are used as a backup. --- puzzlepiece/param.py | 19 ++++++++++++------- puzzlepiece/puzzle.py | 3 +++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/puzzlepiece/param.py b/puzzlepiece/param.py index 351c800..e8b8fc8 100644 --- a/puzzlepiece/param.py +++ b/puzzlepiece/param.py @@ -1,4 +1,4 @@ -from pyqtgraph.Qt import QtWidgets, QtCore +from pyqtgraph.Qt import QtWidgets, QtCore, QtGui from functools import wraps import numpy as np @@ -67,6 +67,11 @@ def __init__( layout.setContentsMargins(0, 0, 0, 0) self.setLayout(layout) + # Background colour + pal = QtGui.QPalette() + pal.setColor(QtGui.QPalette.Window, QtGui.QColor(252, 217, 202, 255)) + self.setPalette(pal) + # Give the param a label self.label = QtWidgets.QLabel() self.label.setText(name + ":") @@ -80,7 +85,7 @@ def __init__( self._value = self._type(value) if self._value is None: # Highlight that the setter or getter haven't been called yet - self.input.setStyleSheet("QWidget { background-color: #fcd9ca; }") + self.setAutoFillBackground(True) layout.addWidget(self.input, 0, 1) # self.set_value(value) @@ -112,8 +117,8 @@ def _make_get_button(self): def _value_change_handler(self): if self._setter is not None: - # Highlight the input box if a setter is set - self.input.setStyleSheet("QWidget { background-color: #fcd9ca; }") + # Highlight the param box if a setter is set + self.setAutoFillBackground(True) else: # If there's no setter, we call set_value to set the value from input self.set_value() @@ -161,7 +166,7 @@ def set_value(self, value=None): self._value = value # Clear the highlight and emit the changed signal - self.input.setStyleSheet("") + self.setAutoFillBackground(False) self.changed.emit() return self._value @@ -180,7 +185,7 @@ def get_value(self): # Set the value to the input and emit signal if needed self._input_set_value(new_value) - self.input.setStyleSheet("") + self.setAutoFillBackground(False) self.changed.emit() return new_value @@ -293,7 +298,7 @@ def getter(): # When a param is created and has an explicit setter, it will be highlighted # red to indicate the setter has not been called. Here we remove the highlight # for the child if the parent's setter has been called already. - child.input.setStyleSheet("") + child.setAutoFillBackground(False) return child diff --git a/puzzlepiece/puzzle.py b/puzzlepiece/puzzle.py index 771f86e..5c5ef97 100644 --- a/puzzlepiece/puzzle.py +++ b/puzzlepiece/puzzle.py @@ -481,6 +481,8 @@ def add_piece(self, name, piece): # No title or border displayed when Piece in Folder piece.setTitle(None) piece.setStyleSheet("QGroupBox {border:0;}") + # Remove most of the border if the stylesheet fails + piece.setFlat(True) return piece @@ -517,6 +519,7 @@ def _replace_piece(self, name, old_piece, new_piece): # No title or border displayed when Piece in Folder new_piece.setTitle(None) new_piece.setStyleSheet("QGroupBox {border:0;}") + new_piece.setFlat(True) self.pieces.remove(old_piece) self.pieces.append(new_piece) From 6815cd091e4fe6ccd80808a458311eb95a7f31a7 Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Fri, 13 Sep 2024 22:15:16 +0100 Subject: [PATCH 10/19] LiveWorker done_signal Allows detecting when the loop is stopped, and automatic unchecking of the PuzzleTimer checkbox --- puzzlepiece/threads.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/puzzlepiece/threads.py b/puzzlepiece/threads.py index f2ae132..d4ea825 100644 --- a/puzzlepiece/threads.py +++ b/puzzlepiece/threads.py @@ -89,6 +89,10 @@ def run(self): self.done = True +class _Done_Emitter(QtCore.QObject): + signal = QtCore.Signal() + + class LiveWorker(Worker): """ A Worker that calls a function repeatedly in a thread, @@ -106,6 +110,10 @@ def __init__(self, function, sleep=0.1, args=None, kwargs=None): super().__init__(function, args, kwargs) #: A Qt signal emitted each time the function returns, passes the returned value to the connected Slot. self.returned = self.returned + # The above line is there for documentation to compile correctly + self._done_emitter = _Done_Emitter() + #: A Qt signal emitted when the LiveWorker is stopped. + self.done_signal = self._done_emitter.signal def stop(self): """ @@ -128,6 +136,7 @@ def run(self): time.sleep(self.sleep) finally: self.done = True + self.done_signal.emit() class PuzzleTimer(QtWidgets.QWidget): @@ -170,6 +179,7 @@ def _state_handler(self, state): if state and (self.worker is None or self.worker.done): self.worker = LiveWorker(self.function, self._sleep, self.args, self.kwargs) self.worker.returned.connect(self._return_handler) + self.worker.done_signal.connect(self.stop) self.puzzle.run_worker(self.worker) elif self.worker is not None: self.worker.stop() From e3fb3a31b257eb5fe0c208d8c5c9a71260c0d10c Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Fri, 13 Sep 2024 22:24:47 +0100 Subject: [PATCH 11/19] Fixing a QPalette enum Should really migrate everything to QtPy already --- puzzlepiece/param.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/puzzlepiece/param.py b/puzzlepiece/param.py index e8b8fc8..600d647 100644 --- a/puzzlepiece/param.py +++ b/puzzlepiece/param.py @@ -1,8 +1,12 @@ -from pyqtgraph.Qt import QtWidgets, QtCore, QtGui +from qtpy import QtWidgets, QtCore, QtGui from functools import wraps import numpy as np +_red_bg_palette = QtGui.QPalette() +_red_bg_palette.setColor(_red_bg_palette.ColorRole.Window, QtGui.QColor(252, 217, 202, 255)) + + class BaseParam(QtWidgets.QWidget): """ A param object represents a gettable/settable value within a :class:`~puzzlepiece.piece.Piece`. This can be @@ -66,11 +70,7 @@ def __init__( self._main_layout = layout = QtWidgets.QGridLayout() layout.setContentsMargins(0, 0, 0, 0) self.setLayout(layout) - - # Background colour - pal = QtGui.QPalette() - pal.setColor(QtGui.QPalette.Window, QtGui.QColor(252, 217, 202, 255)) - self.setPalette(pal) + self.setPalette(_red_bg_palette) # Give the param a label self.label = QtWidgets.QLabel() From 20913111836bcf496235f39402a65e8128254945 Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Fri, 27 Sep 2024 11:43:13 +0100 Subject: [PATCH 12/19] puzzle's call_stop stops threads too --- puzzlepiece/puzzle.py | 1 + 1 file changed, 1 insertion(+) diff --git a/puzzlepiece/puzzle.py b/puzzlepiece/puzzle.py index 5c5ef97..ae83b99 100644 --- a/puzzlepiece/puzzle.py +++ b/puzzlepiece/puzzle.py @@ -337,6 +337,7 @@ def __save_export(): def _call_stop(self): for piece_name in self.pieces: self.pieces[piece_name].call_stop() + self._shutdown_threads.emit() def _button_layout(self): layout = QtWidgets.QHBoxLayout() From 2af72907bbb48ff8605617c73d2fd8babc26dead Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Tue, 1 Oct 2024 11:17:40 +0100 Subject: [PATCH 13/19] piece:param syntax for indexing a PieceDict/Puzzle Makes code more concise and autocomplete easier --- puzzlepiece/puzzle.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/puzzlepiece/puzzle.py b/puzzlepiece/puzzle.py index ae83b99..a2ce7ab 100644 --- a/puzzlepiece/puzzle.py +++ b/puzzlepiece/puzzle.py @@ -97,7 +97,19 @@ def pieces(self): A :class:`~puzzlepiece.puzzle.PieceDict`, effectively a dictionary of :class:`~puzzlepiece.piece.Piece` objects. Can be used to access Pieces from within other Pieces. - You can also directly index the Puzzle object with the Piece name. + You can also directly index the Puzzle object with the :class:`~puzzlepiece.piece.Piece` name, + or even with a :class:`~puzzlepiece.piece.Piece` and a :class:`~puzzlepiece.param.BaseParam`:: + + # These two are equivalent + puzzle.pieces["piece_name"] + puzzle["piece_name"] + + # These three are equivalent + puzzle.pieces["piece_name"].params["piece_name"] + puzzle["piece_name"]["param_name"] + puzzle["piece_name:param_name"] + + The valid keys for indexing a Puzzle object are available when autocompleting the key in IPython. """ return self._pieces @@ -363,7 +375,10 @@ def __getitem__(self, name): return self.pieces[name] def _ipython_key_completions_(self): - return self.pieces.keys() + l = list(self.pieces.keys()) + for piece in self.pieces.keys(): + l.extend([f"{piece}:{param}" for param in self.pieces[piece].params]) + return l def run(self, text): """ @@ -600,6 +615,8 @@ class PieceDict: """ A dictionary wrapper that enforces single-use of keys, and raises a more useful error when a Piece tries to use another Piece that hasn't been registered. + + It also allows indexing params directly by using this key format: ``[piece_name]:[param_name]``. """ def __init__(self): @@ -616,6 +633,12 @@ def __iter__(self): def __getitem__(self, key): if key not in self._dict: + try: + piece, param = key.split(":") + return self._dict[piece][param] + except ValueError: + # key is not in the piece:param format + pass raise KeyError( "A Piece with id '{}' is required, but doesn't exist".format(key) ) From c732e318afea979c2bac2e445e9d3a0682b26380 Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Tue, 1 Oct 2024 11:18:10 +0100 Subject: [PATCH 14/19] IPython shims two helper magics that make working in Notebooks easier --- .../puzzlepiece.extras.ipython_shims.rst | 41 +++++++++++++++++++ docs/source/puzzlepiece.extras.rst | 6 ++- puzzlepiece/extras/ipython_shims.py | 38 +++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 docs/source/puzzlepiece.extras.ipython_shims.rst create mode 100644 puzzlepiece/extras/ipython_shims.py diff --git a/docs/source/puzzlepiece.extras.ipython_shims.rst b/docs/source/puzzlepiece.extras.ipython_shims.rst new file mode 100644 index 0000000..cda583f --- /dev/null +++ b/docs/source/puzzlepiece.extras.ipython_shims.rst @@ -0,0 +1,41 @@ +puzzlepiece.extras.ipython_shims module +======================================= + +Import this module to get access to useful puzzlepiece-related Magics:: + + from puzzlepiece.extras import ipython_shims + +%%pzp_script +------------ + +Magic for running the cell as puzzlepiece script:: + + %%pzp_script + set:piece_name:param_name:25 + prompt:Param changed + +Assumes the :class:`~puzzlepiece.puzzle.Puzzle` is in the global namespace +as ``puzzle``. A different name can be provided as an argument to the magic: +``%%pzp_script puzzle_variable``. + +See :func:`puzzlepiece.parse.run` for the full scripting syntax. + +%%safe_run +------------ + +Magic for running the cell with a safety function in case of a crash:: + + # Cell 1 + def close_shutter(): + puzzle["shutter"]["open"].set_value(0) + + # Cell 2 + %%safe_run close_shutter + raise Exception + +Running cell 2 will raise an exception, but the magic catches the exception, +closes the shutter, and then the original exception is re-raised. Another +useful usecase is sending a Slack message when a crash occurs. + +This is similar in spirit to :func:`puzzlepiece.puzzle.Puzzle.custom_excepthook`, +but works in Notebooks in constrast to that. \ No newline at end of file diff --git a/docs/source/puzzlepiece.extras.rst b/docs/source/puzzlepiece.extras.rst index 202e87e..e354229 100644 --- a/docs/source/puzzlepiece.extras.rst +++ b/docs/source/puzzlepiece.extras.rst @@ -2,8 +2,9 @@ puzzlepiece.extras module ========================= The extras module contains additional features that are useful, but not part of the -core puzzlepiece functionality. Currently this is just the ``datagrid`` - a Widget that -can be added to your Pieces, with multiple Piece-like Rows that use params for data storage +core puzzlepiece functionality. Currently this ``ipython_shims`` (a small collection +of Notebook magics) and the ``datagrid`` - a Widget that can be added to your Pieces, +with multiple Piece-like Rows that use params for data storage and manipulation. The extras modules have to be imported explicitly:: @@ -17,4 +18,5 @@ The extras modules have to be imported explicitly:: .. toctree:: :maxdepth: 2 + puzzlepiece.extras.ipython_shims puzzlepiece.extras.datagrid diff --git a/puzzlepiece/extras/ipython_shims.py b/puzzlepiece/extras/ipython_shims.py new file mode 100644 index 0000000..9ffc6e4 --- /dev/null +++ b/puzzlepiece/extras/ipython_shims.py @@ -0,0 +1,38 @@ +try: + from IPython.core.magic import cell_magic, Magics, magics_class + from IPython import get_ipython +except ModuleNotFoundError: + raise Exception("This module needs to be imported within an IPython environment.") +import traceback + + +@magics_class +class CustomMagics(Magics): + @cell_magic + def pzp_script(self, line, cell): + puzzle = line if len(line) else "puzzle" + self.shell.ex(f'{puzzle}.run("""{cell}""")') + + # The skeleton for this magic comes from https://stackoverflow.com/a/54890975 + @cell_magic + def safe_run(self, line, cell): + try: + self.shell.ex(cell) # This executes the cell in the current namespace + except Exception as e: + try: + # Check the provided line is callable + if len(line) and ip.ev(f'callable({line})'): + # Save the error to the global namespace and execute the provided handler + self.shell.user_ns['error'] = e + ip.ev(f'{line}(error)') + except Exception as ee: + print('The provided exception handler failed to run:', str(ee)) + # Re-raise the error + raise e + +# Register +ip = get_ipython() +ip.register_magics(CustomMagics) + +def format_exception(error): + return ''.join(traceback.format_tb(error.__traceback__)) + f'\n{type(error).__name__}: {error}' \ No newline at end of file From 72d2119e1de5658a7cbe5e5ea530d36a46746b79 Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Mon, 21 Oct 2024 17:03:01 +0100 Subject: [PATCH 15/19] Child actions and general children docs --- puzzlepiece/action.py | 10 ++++++++++ puzzlepiece/param.py | 19 +++++++++++++++++++ puzzlepiece/piece.py | 24 ++++++++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/puzzlepiece/action.py b/puzzlepiece/action.py index eea7710..90c1173 100644 --- a/puzzlepiece/action.py +++ b/puzzlepiece/action.py @@ -45,6 +45,16 @@ def visible(self): Bool flag, indicates whether this action is visible as a button in the GUI. """ return self._visible + + def make_child_action(self): + """ + Create and return a child action that calls the same callable. + + See :func:`puzzlepiece.piece.Popup.add_child_actions` for a quick way of adding child + actions to a popup. + """ + child = Action(self.function, self.parent, self.shortcut) + return child def define(piece, name, shortcut=None, visible=True): diff --git a/puzzlepiece/param.py b/puzzlepiece/param.py index 600d647..016ccfc 100644 --- a/puzzlepiece/param.py +++ b/puzzlepiece/param.py @@ -275,6 +275,25 @@ def _input_get_value(self): return self._type(self.input.text()) def make_child_param(self, kwargs=None): + """ + Create and return a child param. Changing the value of the child changes the value + of the parent, but not vice versa - each child has a getter that allows for refreshing + the value from the parent. + + The child will be of the same type as the parent - a checkbox, spinbox, etc. + + The parent's getter will be called when you :func:`~puzzlepiece.param.BaseParam.get_value` + on the child. The parent's setter will be called when you + :func:`~puzzlepiece.param.BaseParam.set_value` on a child. + + You may need to override this method when creating params that have a different call + signature for ``__init__``. Additional arguments can then be provided with ``kwargs``. + + See :func:`puzzlepiece.piece.Popup.add_child_params` for a quick way of adding child + params to a popup. + + :param kwargs: Additional arguments to pass when creating the child. + """ # Only make an explicit setter if this param has an explicit setter. # The other case is handled via a Signal below, once the child # param is created. diff --git a/puzzlepiece/piece.py b/puzzlepiece/piece.py index d277939..652764e 100644 --- a/puzzlepiece/piece.py +++ b/puzzlepiece/piece.py @@ -324,9 +324,33 @@ def parent_piece(self): return self._parent_piece def add_child_params(self, param_names): + """ + Given a list of param names referring to params of the parent :class:`~puzzlepiece.piece.Piece`, + add corresponding child params to this Popup. + + This lets you quickly make a Settings popup that adjusts the hidden params of a Piece. + + See :func:`puzzlepiece.param.BaseParam.make_child_param` for details. + + :param param_names: List of the parent_piece's param names to make children from. + """ for name in param_names: self.params[name] = self.parent_piece.params[name].make_child_param() + def add_child_actions(self, action_names): + """ + Given a list of action names referring to actions of the parent :class:`~puzzlepiece.piece.Piece`, + add corresponding child actions to this Popup. + + This lets you surface additional actions in a Popup without cluttering the main Piece. + + See :func:`puzzlepiece.action.Action.make_child_action` for details. + + :param action_names: List of the parent_piece's action names to make children from. + """ + for name in action_names: + self.actions[name] = self.parent_piece.actions[name].make_child_action() + # TODO: A way to close the Popup from 'within' def handle_close(self): From 386b9f4e3c2a69652ebdecaf46be4fa72a9d68be Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Thu, 24 Oct 2024 14:43:44 +0100 Subject: [PATCH 16/19] Docs and formatting --- docs/source/puzzlepiece.extras.rst | 2 +- puzzlepiece/action.py | 2 +- puzzlepiece/extras/ipython_shims.py | 17 ++++++++++------ puzzlepiece/param.py | 15 ++++++++++---- puzzlepiece/puzzle.py | 31 ++++++++++++++++++++--------- 5 files changed, 46 insertions(+), 21 deletions(-) diff --git a/docs/source/puzzlepiece.extras.rst b/docs/source/puzzlepiece.extras.rst index e354229..33e2496 100644 --- a/docs/source/puzzlepiece.extras.rst +++ b/docs/source/puzzlepiece.extras.rst @@ -2,7 +2,7 @@ puzzlepiece.extras module ========================= The extras module contains additional features that are useful, but not part of the -core puzzlepiece functionality. Currently this ``ipython_shims`` (a small collection +core puzzlepiece functionality. Currently these are ``ipython_shims`` (a small collection of Notebook magics) and the ``datagrid`` - a Widget that can be added to your Pieces, with multiple Piece-like Rows that use params for data storage and manipulation. diff --git a/puzzlepiece/action.py b/puzzlepiece/action.py index 90c1173..ca5b759 100644 --- a/puzzlepiece/action.py +++ b/puzzlepiece/action.py @@ -45,7 +45,7 @@ def visible(self): Bool flag, indicates whether this action is visible as a button in the GUI. """ return self._visible - + def make_child_action(self): """ Create and return a child action that calls the same callable. diff --git a/puzzlepiece/extras/ipython_shims.py b/puzzlepiece/extras/ipython_shims.py index 9ffc6e4..3d8575e 100644 --- a/puzzlepiece/extras/ipython_shims.py +++ b/puzzlepiece/extras/ipython_shims.py @@ -12,7 +12,7 @@ class CustomMagics(Magics): def pzp_script(self, line, cell): puzzle = line if len(line) else "puzzle" self.shell.ex(f'{puzzle}.run("""{cell}""")') - + # The skeleton for this magic comes from https://stackoverflow.com/a/54890975 @cell_magic def safe_run(self, line, cell): @@ -21,18 +21,23 @@ def safe_run(self, line, cell): except Exception as e: try: # Check the provided line is callable - if len(line) and ip.ev(f'callable({line})'): + if len(line) and ip.ev(f"callable({line})"): # Save the error to the global namespace and execute the provided handler - self.shell.user_ns['error'] = e - ip.ev(f'{line}(error)') + self.shell.user_ns["error"] = e + ip.ev(f"{line}(error)") except Exception as ee: - print('The provided exception handler failed to run:', str(ee)) + print("The provided exception handler failed to run:", str(ee)) # Re-raise the error raise e + # Register ip = get_ipython() ip.register_magics(CustomMagics) + def format_exception(error): - return ''.join(traceback.format_tb(error.__traceback__)) + f'\n{type(error).__name__}: {error}' \ No newline at end of file + return ( + "".join(traceback.format_tb(error.__traceback__)) + + f"\n{type(error).__name__}: {error}" + ) diff --git a/puzzlepiece/param.py b/puzzlepiece/param.py index 016ccfc..ff2a8f6 100644 --- a/puzzlepiece/param.py +++ b/puzzlepiece/param.py @@ -4,7 +4,9 @@ _red_bg_palette = QtGui.QPalette() -_red_bg_palette.setColor(_red_bg_palette.ColorRole.Window, QtGui.QColor(252, 217, 202, 255)) +_red_bg_palette.setColor( + _red_bg_palette.ColorRole.Window, QtGui.QColor(252, 217, 202, 255) +) class BaseParam(QtWidgets.QWidget): @@ -281,7 +283,7 @@ def make_child_param(self, kwargs=None): the value from the parent. The child will be of the same type as the parent - a checkbox, spinbox, etc. - + The parent's getter will be called when you :func:`~puzzlepiece.param.BaseParam.get_value` on the child. The parent's setter will be called when you :func:`~puzzlepiece.param.BaseParam.set_value` on a child. @@ -306,8 +308,13 @@ def getter(): kwargs = kwargs or {} child = type(self)( - self._name, self._value, setter=setter, getter=getter, - format=self._format, _type=self._type, **kwargs + self._name, + self._value, + setter=setter, + getter=getter, + format=self._format, + _type=self._type, + **kwargs, ) if self._setter is None: diff --git a/puzzlepiece/puzzle.py b/puzzlepiece/puzzle.py index a2ce7ab..15c42d0 100644 --- a/puzzlepiece/puzzle.py +++ b/puzzlepiece/puzzle.py @@ -1,7 +1,6 @@ from . import parse from pyqtgraph.Qt import QtWidgets, QtCore -import importlib import sys @@ -149,14 +148,29 @@ def add_piece(self, name, piece, row, column, rowspan=1, colspan=1): self.register_piece(name, piece) return piece - + def replace_piece(self, name, new_piece): + """ + Replace a named :class:`~puzzlepiece.piece.Piece` with a new one. Can be + combined with ``importlib.reload`` to do live development on Pieces. + + This method is **experimental** and can sometimes fail. It's useful for development, + but shouldn't really be used in production applications. + + :param name: Name of the Piece to be replaced. + :param piece: A :class:`~puzzlepiece.piece.Piece` object or a class defining one (which will + be automatically instantiated). + """ old_piece = self.pieces[name] if isinstance(new_piece, type): new_piece = new_piece(self) if old_piece in self._toplevel: - self.layout.replaceWidget(old_piece, new_piece, options=QtCore.Qt.FindChildOption.FindDirectChildrenOnly) + self.layout.replaceWidget( + old_piece, + new_piece, + options=QtCore.Qt.FindChildOption.FindDirectChildrenOnly, + ) new_piece.setTitle(name) self._toplevel.remove(old_piece) self._toplevel.append(new_piece) @@ -171,7 +185,6 @@ def replace_piece(self, name, new_piece): old_piece.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True) old_piece.close() - def add_folder(self, row, column, rowspan=1, colspan=1): """ Adds a tabbed :class:`~puzzlepiece.puzzle.Folder` to the grid layout, and returns it. @@ -375,10 +388,10 @@ def __getitem__(self, name): return self.pieces[name] def _ipython_key_completions_(self): - l = list(self.pieces.keys()) + values = list(self.pieces.keys()) for piece in self.pieces.keys(): - l.extend([f"{piece}:{param}" for param in self.pieces[piece].params]) - return l + values.extend([f"{piece}:{param}" for param in self.pieces[piece].params]) + return values def run(self, text): """ @@ -582,7 +595,7 @@ def add_piece(self, name, piece, row, column, rowspan=1, colspan=1): piece.folder = self return piece - + def _replace_piece(self, name, old_piece, new_piece): if old_piece in self.pieces: self.layout.replaceWidget(old_piece, new_piece) @@ -643,7 +656,7 @@ def __getitem__(self, key): "A Piece with id '{}' is required, but doesn't exist".format(key) ) return self._dict[key] - + def _replace_item(self, key, value): self._dict[key] = value From 27862a001b1fedd1c9258d5bfd95ddc744131999 Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Thu, 24 Oct 2024 14:43:57 +0100 Subject: [PATCH 17/19] Tutorial --- docs/requirements.txt | 1 + docs/source/conf.py | 4 +- docs/source/index.rst | 3 + docs/source/tutorial.ipynb | 822 +++++++++++++++++++++++++++++++++++++ 4 files changed, 829 insertions(+), 1 deletion(-) create mode 100644 docs/source/tutorial.ipynb diff --git a/docs/requirements.txt b/docs/requirements.txt index e1ef30c..5d631bc 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -5,3 +5,4 @@ pyqtgraph==0.13.3 numpy==1.26.2 sphinx==7.2.5 sphinx-rtd-theme==1.3.0rc1 +nbsphinx==0.9.5 \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index d4ea0e2..d83e98c 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -19,7 +19,9 @@ # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -extensions = ["sphinx.ext.autodoc", "sphinx.ext.viewcode", "sphinx.ext.napoleon"] +extensions = [ + "sphinx.ext.autodoc", "sphinx.ext.viewcode", "sphinx.ext.napoleon", + "nbsphinx", "sphinx.ext.autosectionlabel"] templates_path = ["_templates"] exclude_patterns = [] diff --git a/docs/source/index.rst b/docs/source/index.rst index 8ba6b4b..8c3d5db 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -150,6 +150,8 @@ you may want to update ``ipykernel`` if your cells are not running after an exce Next steps ========== +The :ref:`Tutorial` is a great place to start - have a look or run it yourself to learn interactively! + Some more detailed examples are located on GitHub: `how to construct an app `_ or `how to code a Piece `_. Examples of more complex Pieces `are also available `_. @@ -162,6 +164,7 @@ This documentation is meant as a good way to familiarise yourself with the libra :caption: Contents: modules + tutorial Indices and tables diff --git a/docs/source/tutorial.ipynb b/docs/source/tutorial.ipynb new file mode 100644 index 0000000..99fa499 --- /dev/null +++ b/docs/source/tutorial.ipynb @@ -0,0 +1,822 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "1ee71176-df4c-4692-b0f1-457780083eca", + "metadata": {}, + "source": [ + "# Tutorial\n", + "This is a thorough introduction to the basics of puzzlepiece. You can read through this Notebook here, but it's probably nicer to run it yourself! You can download it from https://github.com/jdranczewski/puzzlepiece/blob/main/docs/source/tutorial.ipynb\n", + "\n", + "You need puzzlepiece and some version of Qt (PySide6 or PyQt6 for example) installed to run this. Note that Anaconda installation comes with PyQt5 already!\n", + "\n", + "```pip install puzzlepiece```\n", + "\n", + "Other requirements include `numpy` and `tqdm`.\n", + "\n", + "Docs are at https://puzzlepiece.readthedocs.io/en/stable/index.html - you can also press shift-tab when your cursor is \"inside\" at method (for example `pzp.param.spinb|ox` or `pzp.param.spinbox(|`) to bring up the help text for that specific function. Good luck!" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "b3f67270-598a-4913-b506-357e00d9e6e2", + "metadata": {}, + "outputs": [], + "source": [ + "# Enable the GUI integration for this Notebook\n", + "%gui qt" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "5b35ecdb-554d-4abc-8e13-58ead857dce9", + "metadata": {}, + "outputs": [], + "source": [ + "# Main GUI framework\n", + "import puzzlepiece as pzp\n", + "# Plotting framework\n", + "import pyqtgraph as pg\n", + "# Progress bar library\n", + "from tqdm import tqdm\n", + "# A way to access Qt Widgets (independent of whether the user has PyQt or PySide installed)\n", + "from qtpy import QtWidgets\n", + "# Other libraries\n", + "import numpy as np\n", + "import time" + ] + }, + { + "cell_type": "markdown", + "id": "25a3a3ab-f9ab-4b41-b479-f615468d5ee7", + "metadata": {}, + "source": [ + "## Our first Piece\n", + "This one is pretty boring and empty." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "de86e228-ec60-4d25-bff6-92f36608d3ea", + "metadata": {}, + "outputs": [], + "source": [ + "class Piece(pzp.Piece):\n", + " pass" + ] + }, + { + "cell_type": "markdown", + "id": "dd7245f6-26cc-4893-a6f5-f5e5eaeeee75", + "metadata": {}, + "source": [ + "Let's add it to a Puzzle. A tiny new window should appear. You can close this window when moving on to the next section." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "1008ffda-fda3-40dd-ae01-4af99e3d718c", + "metadata": {}, + "outputs": [], + "source": [ + "puzzle = pzp.Puzzle()\n", + "# The Pieces are added on a grid\n", + "puzzle.add_piece(\"piece_name\", Piece, row=0, column=0)\n", + "# In Qt you need to show a Widget for it to appear\n", + "puzzle.show()" + ] + }, + { + "cell_type": "markdown", + "id": "c2edb301-125d-4bd8-96a0-70ffd667418d", + "metadata": {}, + "source": [ + "## Adding params\n", + "This Piece will show a text box that you can edit" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "acaa5648-bb66-4e48-aa01-8a9ef4862ef3", + "metadata": {}, + "outputs": [], + "source": [ + "class Piece(pzp.Piece):\n", + " def define_params(self):\n", + " # You will define your params in here\n", + " # The (None) indicates that this param has no getter or setter (we'll get to these)\n", + " pzp.param.text(self, \"name\", \"Jakub\")(None)\n", + "\n", + "puzzle = pzp.Puzzle()\n", + "puzzle.add_piece(\"piece_name\", Piece, 0, 0)\n", + "puzzle.show()" + ] + }, + { + "cell_type": "markdown", + "id": "62af433f-ee16-4b6d-93b8-21d112543cde", + "metadata": {}, + "source": [ + "## Params with getters\n", + "Some params can call a function to get a value (intensity from a powermeter, say).\n", + "\n", + "Click the \"refresh\" button to get a value." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "5bbd6f4a-af0f-4eea-b5e7-e66da0b9de9e", + "metadata": {}, + "outputs": [], + "source": [ + "class Piece(pzp.Piece):\n", + " def define_params(self):\n", + " pzp.param.text(self, \"name\", \"Jakub\")(None)\n", + "\n", + " # A spinbox is a number input\n", + " pzp.param.spinbox(self, \"born\", 1999)(None)\n", + " pzp.param.spinbox(self, \"now\", 2024)(None)\n", + "\n", + " # This param has a getter - a function to obtain its value\n", + " # This is achieved by using `readout` as a \"decorator\" on a function (spot the @, and lack of (None))\n", + " @pzp.param.readout(self, \"age\")\n", + " def age(self):\n", + " # This method accesses the other two params to compute an age\n", + " return self[\"now\"].value - self[\"born\"].value\n", + "\n", + "puzzle = pzp.Puzzle()\n", + "puzzle.add_piece(\"piece_name\", Piece, 0, 0)\n", + "puzzle.show()" + ] + }, + { + "cell_type": "markdown", + "id": "1ea423d8-d1a0-42b0-8304-45b45f84b395", + "metadata": {}, + "source": [ + "## Params with setters\n", + "Some params call a function to set a value (for example the integration time).\n", + "\n", + "Note that the text field gets red when you edit it - this indicates that the text in the box changed, but the setter has not yet been called." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "3209b136-3981-44be-a752-fefd0ce1cd9d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The user's name is now Jakub\n" + ] + } + ], + "source": [ + "class Piece(pzp.Piece):\n", + " def define_params(self):\n", + " # Notice that we're using `text` as a decorator again - whether this makes the method below a getter or a setter depends on the input type.\n", + " # Text boxes and spinboxes have setters by default, readouts and arrays have getters. Check https://puzzlepiece.readthedocs.io/en/stable/puzzlepiece.param.html\n", + " # for all available input decorators and their default behaviour\n", + " @pzp.param.text(self, \"name\", \"Jakub\")\n", + " def name(self, value):\n", + " # `value` is the new value of the param\n", + " print(\"The user's name is now\", value)\n", + "\n", + " pzp.param.spinbox(self, \"born\", 1999)(None)\n", + " pzp.param.spinbox(self, \"now\", 2024)(None)\n", + "\n", + " @pzp.param.readout(self, \"age\")\n", + " def age(self):\n", + " return self[\"now\"].value - self[\"born\"].value\n", + "\n", + "puzzle = pzp.Puzzle()\n", + "puzzle.add_piece(\"piece_name\", Piece, 0, 0)\n", + "puzzle.show()" + ] + }, + { + "cell_type": "markdown", + "id": "86e994b0-9840-46e3-9212-2bfe14a89d9d", + "metadata": {}, + "source": [ + "## Params with getters and setters\n", + "Some params may have a getter and a setter simultaneously (you can set an integration time, but you can also ask the device what it is)." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "ca11d976-9c37-47b9-b667-15d8c4322a9c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The user's name is now William\n" + ] + } + ], + "source": [ + "class Piece(pzp.Piece):\n", + " def define_params(self):\n", + " @pzp.param.text(self, \"name\", \"Jakub\")\n", + " def name(self, value):\n", + " print(\"The user's name is now\", value)\n", + " # We need to return the value here to acknowledge that we've set it, otherwise the getter will be called\n", + " # to double check it. See https://puzzlepiece.readthedocs.io/en/stable/puzzlepiece.param.html#puzzlepiece.param.BaseParam.set_value\n", + " # for the details of this logic.\n", + " return value\n", + "\n", + " # Here, we're using the `set_getter` method of the name param to add a getter to it (it already has a setter) \n", + " @name.set_getter(self)\n", + " def name(self):\n", + " return np.random.choice(['James', 'John', 'Robert', 'Michael', 'William', 'David'])\n", + "\n", + " pzp.param.spinbox(self, \"born\", 1999)(None)\n", + " pzp.param.spinbox(self, \"now\", 2024)(None)\n", + "\n", + " @pzp.param.readout(self, \"age\")\n", + " def age(self):\n", + " return self[\"now\"].value - self[\"born\"].value\n", + "\n", + "puzzle = pzp.Puzzle()\n", + "puzzle.add_piece(\"piece_name\", Piece, 0, 0)\n", + "puzzle.show()" + ] + }, + { + "cell_type": "markdown", + "id": "3c88ce74-73aa-4186-89a5-b2aeaa24798e", + "metadata": {}, + "source": [ + "## Actions\n", + "Sometimes you need to do something, like save an image from a camera.\n", + "\n", + "Note that the gretting prints \"Hello None\" until the name is explicitly set - params with setters hold no value internally until the setter is called." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "511b2cd8-eaca-4543-959d-321e6d93e0cf", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Hello None your age is 25\n", + "The user's name is now Jakub\n", + "Hello Jakub your age is 25\n" + ] + } + ], + "source": [ + "class Piece(pzp.Piece):\n", + " def define_params(self):\n", + " @pzp.param.text(self, \"name\", \"Jakub\")\n", + " def name(self, value):\n", + " print(\"The user's name is now\", value)\n", + " return value\n", + " \n", + " @name.set_getter(self)\n", + " def name(self):\n", + " return np.random.choice(['James', 'John', 'Robert', 'Michael', 'William', 'David'])\n", + "\n", + " pzp.param.spinbox(self, \"born\", 1999)(None)\n", + " pzp.param.spinbox(self, \"now\", 2024)(None)\n", + "\n", + " @pzp.param.readout(self, \"age\")\n", + " def age(self):\n", + " return self[\"now\"].value - self[\"born\"].value\n", + "\n", + " def define_actions(self):\n", + " # we define our actions here, using decorators on the functions\n", + " @pzp.action.define(self, \"Greet\")\n", + " def greet(self):\n", + " # Note the difference between .value and .get_value()\n", + " # .value accesses the interally stored param value, not calling the getter (which would return a random name here)\n", + " # .get_value calls the getter if there's one (in this case to calculate the age)\n", + " print(\"Hello\", self[\"name\"].value, \"your age is\", self[\"age\"].get_value())\n", + "\n", + "puzzle = pzp.Puzzle()\n", + "puzzle.add_piece(\"piece_name\", Piece, 0, 0)\n", + "puzzle.show()" + ] + }, + { + "cell_type": "markdown", + "id": "6f9e2ada-30bd-47d2-960f-2697c3263179", + "metadata": {}, + "source": [ + "## Accessing params and actions from code\n", + "You've seen glimpses of this already, but there's two ways to interact with a Piece. We can click through the GUI, or we can use the API from code to set, get, and run actions.\n", + "\n", + "Keep the Puzzle created below open while you run the subsequent cells and observe how it changes." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "b63aa431-619f-465a-af93-565d4f6bc276", + "metadata": {}, + "outputs": [], + "source": [ + "puzzle = pzp.Puzzle()\n", + "puzzle.add_piece(\"piece1\", Piece, 0, 0)\n", + "puzzle.add_piece(\"piece2\", Piece, 0, column=1)\n", + "puzzle.show()" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "a0391f4a-fcf7-40bf-84b4-31d727258e27", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The user's name is now James\n" + ] + }, + { + "data": { + "text/plain": [ + "'James'" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Note that this will also return the new value\n", + "puzzle[\"piece1\"][\"name\"].set_value(\"James\")" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "dca1d95a-6b37-48bb-8bb0-d1b3a439b01f", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The user's name is now John\n" + ] + }, + { + "data": { + "text/plain": [ + "'John'" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "puzzle[\"piece2\"][\"name\"].set_value(\"John\")" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "9b899070-d391-410e-86ee-f9d6084e0be8", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'James'" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# doesn't call the getter\n", + "puzzle[\"piece1\"][\"name\"].value" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "66393a1c-5378-4b7a-956c-4cb377bb79bd", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'William'" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# does call the getter\n", + "puzzle[\"piece1\"][\"name\"].get_value()" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "667aece3-9369-4c37-825a-cd2ab46b53f1", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "1999" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "puzzle[\"piece2\"][\"born\"].get_value()" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "ad1d50ca-e9b9-4ea7-a2d8-0175fbaddc9a", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "124" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "puzzle[\"piece2\"][\"born\"].set_value(1900)\n", + "puzzle[\"piece2\"][\"age\"].get_value()" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "f900da6d-2d95-46f3-abe1-e5e263544b0f", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "124\n", + "114\n", + "104\n", + "94\n", + "84\n", + "74\n", + "64\n" + ] + } + ], + "source": [ + "for year in range(1900, 1961, 10):\n", + " puzzle[\"piece2\"][\"born\"].set_value(year)\n", + " print(puzzle[\"piece2\"][\"age\"].get_value())" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "ead8a687-4b9c-4ec1-8e12-b8b669a6eb64", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "124\n", + "114\n", + "104\n", + "94\n", + "84\n", + "74\n", + "64\n" + ] + } + ], + "source": [ + "for year in range(1900, 1961, 10):\n", + " puzzle[\"piece2\"][\"born\"].set_value(year)\n", + " print(puzzle[\"piece2\"][\"age\"].get_value())\n", + " # Note that while a function or Notebook cell is running, the Puzzle will only\n", + " # update the GUI if you explicitly tell it too\n", + " puzzle.process_events()\n", + " # delay added to make changes visible\n", + " time.sleep(.1)" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "29d81b2f-323a-437b-8736-162d306536b6", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Hello William your age is 25\n", + "Hello John your age is 64\n" + ] + } + ], + "source": [ + "puzzle[\"piece1\"].actions[\"Greet\"]()\n", + "puzzle[\"piece2\"].actions[\"Greet\"]()" + ] + }, + { + "cell_type": "markdown", + "id": "6e0ce313-aa3e-406a-9d13-cc2b9a909261", + "metadata": {}, + "source": [ + "## Custom layouts\n", + "You can make any Qt Layout appear within your Piece. https://www.pythonguis.com/ is a decent primer on how these work. Here's a TL;DR:" + ] + }, + { + "cell_type": "markdown", + "id": "bf5bb99d-8f91-473f-89df-90789ac82df1", + "metadata": {}, + "source": [ + "* every GUI component in Qt (a button, a text box, a label) is a 'Widget'\n", + "* Widgets go into Layouts - the Layout describes how the Widgets are laid out\n", + "* a Widget is actually a very general concept - any element of your app that's on screen is probably a Widget. For example, a form can be a Widget that contains multiple input box Widgets. It all nests into multiple layers of Widgets containing other Widgets\n", + "* a Widget can contain a Layout as well, which is how this nesting is achieved. So a Widget has a Layout, and other Widgets are placed within this Layout\n", + "* nearly everything in puzzlepiece is secretly a Widget - for example the param objects are Widgets so that they can be displayed inside a Piece\n", + "* Widgets can have Signals and you can 'connect' functions to those signals - the function is then called when the Signal is 'emitted'. For example, the params in puzzlepiece have a 'changed' Signal, which is emitted whenever the param value changes. You can connect functions to this Signal so that they are called each time the param value changes." + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "797d800b-9cc3-45e7-965a-cba20e753ec6", + "metadata": {}, + "outputs": [], + "source": [ + "class RandomPiece(pzp.Piece):\n", + " def define_params(self):\n", + " pzp.param.spinbox(self, \"N\", 100)(None)\n", + " @pzp.param.array(self, \"random\")\n", + " def random(self):\n", + " return np.random.random(self[\"N\"].value)\n", + "\n", + " def custom_layout(self):\n", + " # this method should return a QT Layout that will be placed inside the Piece\n", + " layout = QtWidgets.QVBoxLayout()\n", + "\n", + " # We create a plot Widget (from pyqtgraph) and add it to the Layout\n", + " pw = pg.PlotWidget()\n", + " layout.addWidget(pw)\n", + " # pyqtgraph thinks of things as \"Items\" - the plot is an item, the lines within it are Items,\n", + " # images are ImageItems, etc - for a list see https://pyqtgraph.readthedocs.io/en/latest/api_reference/graphicsItems/index.html\n", + " self.plot = pw.getPlotItem()\n", + " # Add an empty line to the plot\n", + " self.plot_line = self.plot.plot([], [], symbol='o', symbolSize=3)\n", + "\n", + " def update_plot():\n", + " self.plot_line.setData(self[\"random\"].value)\n", + " # We connect `update_plot` to a `Signal` here - whenever the value of the `random`\n", + " # param changes, update_plot is called to update the plot.\n", + " # Click the refresh button next to `random` to see it happen, and change N to see what happens.\n", + " self[\"random\"].changed.connect(update_plot)\n", + " # for bonus points, we should really do\n", + " # self[\"random\"].changed.connect(pzp.threads.CallLater(update_plot))\n", + " # which would only update the plot once when the GUI refreshes\n", + "\n", + " return layout\n", + "\n", + "puzzle = pzp.Puzzle()\n", + "puzzle.add_piece(\"piece_name\", RandomPiece, 0, 0)\n", + "puzzle.show()" + ] + }, + { + "cell_type": "markdown", + "id": "2908e15c-e3b9-4221-a5e9-8656c605c374", + "metadata": {}, + "source": [ + "## Developing your measurement\n", + "You can of course just develop your measurement as a Python method to be run from a Notebook. Notice how the GUI updates only once the measurement is done - we'd need to add a `puzzle.process_events()` to refresh it explicitly." + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "5e659941-cd1d-4b48-bb2f-935f992f264e", + "metadata": {}, + "outputs": [], + "source": [ + "puzzle = pzp.Puzzle()\n", + "puzzle.add_piece(\"random_numbers\", RandomPiece, 0, 0)\n", + "puzzle.show()" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "bf4d9b9c-455a-4315-8a97-5a43757237dd", + "metadata": {}, + "outputs": [], + "source": [ + "def measure(M):\n", + " a = []\n", + " for i in tqdm(range(M)):\n", + " a.append(puzzle[\"random_numbers\"][\"random\"].get_value())\n", + " time.sleep(.1)\n", + " return np.asarray(a)" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "ada53eaf-9f39-4f3d-9eee-d68f0d739f13", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████████████████████████████████████████████████████████████████████████████| 12/12 [00:01<00:00, 9.69it/s]\n" + ] + }, + { + "data": { + "text/plain": [ + "(12, 100)" + ] + }, + "execution_count": 23, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "measure(12).shape" + ] + }, + { + "cell_type": "markdown", + "id": "5e86bed0-560d-42d8-b550-8e719e094da6", + "metadata": {}, + "source": [ + "You can alternatively put your measurement into a Piece, or have a bunch of Pieces to perform various functions:" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "49a7a88c-6179-4dd8-925e-653c7bf03efd", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'test_data_sample{metadata:sample}_{metadata:angle}deg.csv'" + ] + }, + "execution_count": 24, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "class Measurement(pzp.Piece):\n", + " def define_params(self):\n", + " pzp.param.spinbox(self, \"M\", 500)(None)\n", + " pzp.param.checkbox(self, \"gui_update\", 1)(None)\n", + " pzp.param.progress(self, \"progress\")(None)\n", + " pzp.param.text(self, \"filename\", \"\")(None)\n", + " pzp.param.array(self, \"result\")(None)\n", + "\n", + " def define_actions(self):\n", + " @pzp.action.define(self, \"Measure\")\n", + " def measure(self):\n", + " a = []\n", + " # Reset the stop flag\n", + " self.stop = False\n", + " # Indicate progress by using the bar's `iter` method\n", + " for i in self[\"progress\"].iter(range(self[\"M\"].value)):\n", + " a.append(puzzle[\"random_numbers\"][\"random\"].get_value())\n", + " # Break is STOP pressed\n", + " if self.stop:\n", + " break\n", + " # Update the GUI if set to do that\n", + " if self[\"gui_update\"].value:\n", + " puzzle.process_events()\n", + " result = self[\"result\"].set_value(a)\n", + " return result\n", + "\n", + " @pzp.action.define(self, \"Save\")\n", + " def save(self):\n", + " # Use `format` to include metadata in the filename\n", + " fname = pzp.parse.format(self[\"filename\"].value, self.puzzle)\n", + " np.savetxt(\n", + " fname,\n", + " self[\"result\"].value\n", + " )\n", + " puzzle.run(\"prompt:File saved as \" + fname)\n", + "\n", + "class Metadata(pzp.Piece):\n", + " # By making a Metadata Piece, you decouple the exact metadata you want to save (in the filename\n", + " # or wherever) from the Measurement.\n", + " def define_params(self):\n", + " pzp.param.dropdown(self, \"sample\", \"A\")([\"A\", \"B\", \"C\"])\n", + " pzp.param.spinbox(self, \"angle\", 0, v_step=10)(None)\n", + "\n", + "class FilenameHelper(pzp.Piece):\n", + " def define_params(self):\n", + " @pzp.param.text(self, \"filename\", \"\")\n", + " def filename(self, value):\n", + " self.puzzle[\"measurement\"][\"filename\"].set_value(value)\n", + "\n", + "puzzle = pzp.Puzzle()\n", + "puzzle.add_piece(\"random_numbers\", RandomPiece, 0, 0, rowspan=2)\n", + "puzzle.add_piece(\"measurement\", Measurement, 0, 1)\n", + "puzzle.add_piece(\"metadata\", Metadata, 1, 1)\n", + "puzzle.add_piece(\"filename\", FilenameHelper, 2, 0, colspan=2)\n", + "puzzle.show()\n", + "\n", + "puzzle[\"filename\"][\"filename\"].set_value(\"test_data_sample{metadata:sample}_{metadata:angle}deg.csv\")" + ] + }, + { + "cell_type": "markdown", + "id": "9bfddc00-c47e-42fa-a9b2-2876695fdf97", + "metadata": {}, + "source": [ + "* Try to Measure and then Save.\n", + "* Note how `pzp.parse.format` is used to replace the `{}` expressions in the filename with values from the metadata Piece\n", + "* The filename Piece is there mostly to give us a wider textfield compared to the tiny one in the measurement Piece.\n", + "* Notice how `self[\"progress\"].iter` wraps the `range` in the measurement iteration - similar to how `tqdm` can normally be used for progress bars (https://tqdm.github.io/)\n", + "* Note how `self.stop` is used to integrate with the built-in STOP button. A result is still saved to the `result` param if you press STOP!\n", + "* Notice how `puzzle.process_events()` is used to make the plot and progress bar update every iteration - the GUI could freeze without that, but the measurement would run a bit faster. Try either option by toggling the `gui_update` checkbox before measuring." + ] + }, + { + "cell_type": "markdown", + "id": "8fe7cbcd-db32-4809-b2d6-edfd985ab471", + "metadata": {}, + "source": [ + "My advice generally would be to use simple Notebook functions during the development, where the exact measurement you want to do is not clear-cut and you may want to change things about how exactly it works.\n", + "\n", + "Once you have a well-defined measurement, you can put it in a Piece for repeat use!" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.4" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From b437c0ad0994ccf963eb76f13dad2ea9c2d70ab6 Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Thu, 24 Oct 2024 14:48:28 +0100 Subject: [PATCH 18/19] ipykernel for readthedocs --- docs/requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 5d631bc..669e8cc 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -5,4 +5,5 @@ pyqtgraph==0.13.3 numpy==1.26.2 sphinx==7.2.5 sphinx-rtd-theme==1.3.0rc1 -nbsphinx==0.9.5 \ No newline at end of file +nbsphinx==0.9.5 +ipykernel==6.29.5 \ No newline at end of file From 90f7fca2ff625a7b4240f0d2bc5aab89bac585c5 Mon Sep 17 00:00:00 2001 From: jdranczewski Date: Thu, 24 Oct 2024 14:56:57 +0100 Subject: [PATCH 19/19] bump version to 0.11.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f2f71a1..345c626 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "puzzlepiece" -version = "0.10.0" +version = "0.11.0" authors = [ { name="Jakub Dranczewski", email="jakub.dranczewski@gmail.com" }, ]