From f21d4fd257e7d74b80f91c5deb266b00872a6e39 Mon Sep 17 00:00:00 2001 From: Sietze van Buuren Date: Fri, 21 Jun 2024 22:24:06 +0200 Subject: [PATCH 1/4] Work in progress in getting pqthreads support Signed-off-by: Sietze van Buuren --- examples/full.py | 3 +- examples/minimal.py | 3 +- examples/subplot.py | 3 +- examples/surface.py | 3 +- mlpyqtgraph/__init__.py | 47 +++++++- mlpyqtgraph/axes.py | 7 +- mlpyqtgraph/controllers.py | 176 ----------------------------- mlpyqtgraph/descriptors.py | 111 ------------------ mlpyqtgraph/ml_functions.py | 15 ++- mlpyqtgraph/thread_communicator.py | 164 --------------------------- mlpyqtgraph/windows.py | 18 +-- mlpyqtgraph/worker.py | 148 ------------------------ mlpyqtgraph/workers.py | 47 ++++++++ requirements.txt | 1 + 14 files changed, 123 insertions(+), 623 deletions(-) delete mode 100644 mlpyqtgraph/controllers.py delete mode 100644 mlpyqtgraph/descriptors.py delete mode 100644 mlpyqtgraph/thread_communicator.py delete mode 100644 mlpyqtgraph/worker.py create mode 100644 mlpyqtgraph/workers.py diff --git a/examples/full.py b/examples/full.py index 1c390e7..f15a45d 100644 --- a/examples/full.py +++ b/examples/full.py @@ -7,6 +7,7 @@ import mlpyqtgraph as mpg +@mpg.plotter def main(): """ Advanced mlpyqtgraph example """ plot_args = {'width': 2} @@ -22,4 +23,4 @@ def main(): mpg.gca().grid = True if __name__ == '__main__': - mpg.GUIController(worker=main) + main() diff --git a/examples/minimal.py b/examples/minimal.py index fa9cced..1faec2c 100644 --- a/examples/minimal.py +++ b/examples/minimal.py @@ -5,10 +5,11 @@ import mlpyqtgraph as mpg +@mpg.plotter def main(): """ Minimal mlpyqtgraph example """ mpg.plot(range(5), (1, 3, 2, 0, 5)) if __name__ == '__main__': - mpg.GUIController(worker=main) + main() diff --git a/examples/subplot.py b/examples/subplot.py index 09ff83a..af716c5 100644 --- a/examples/subplot.py +++ b/examples/subplot.py @@ -6,6 +6,7 @@ import mlpyqtgraph as mpg +@mpg.plotter def main(): """ Example with subplots """ fig = mpg.figure(title='Third figure') @@ -24,4 +25,4 @@ def main(): if __name__ == '__main__': - mpg.GUIController(worker=main) + main() diff --git a/examples/surface.py b/examples/surface.py index 59a3f66..4e5a9f2 100644 --- a/examples/surface.py +++ b/examples/surface.py @@ -6,6 +6,7 @@ import mlpyqtgraph as mpg +@mpg.plottero(antialiasing=True) def main(): """ Examples with surface plots """ extent = 10 @@ -29,4 +30,4 @@ def main(): if __name__ == '__main__': - mpg.GUIController(worker=main, antialiasing=True) + main() diff --git a/mlpyqtgraph/__init__.py b/mlpyqtgraph/__init__.py index f330b27..b42ad37 100644 --- a/mlpyqtgraph/__init__.py +++ b/mlpyqtgraph/__init__.py @@ -3,8 +3,51 @@ used as interface """ +from mlpyqtgraph import windows +from mlpyqtgraph import axes +from mlpyqtgraph import workers +from mlpyqtgraph import config_options as config + + from . import ml_functions from .ml_functions import * -from . import controllers -from .controllers import GUIController + +GUIAgency = controllers.GUIAgency +GUIAgency.add_agent('figure', windows.FigureWindow) +GUIAgency.add_agent('axis', axes.Axis2D) + + +def plotter(func): + """ Decorator for end user functions, adding figure functionality""" + def func_wrapper(*args, **kwargs): + """ Wrapper """ + gui_agency = GUIAgency(worker=func, *args, **kwargs) + gui_agency.worker_agency.add_container('figure', workers.FigureWorker) + gui_agency.worker_agency.add_container('axis', workers.AxisWorker) + gui_agency.kickoff() + return gui_agency.result + return func_wrapper + + +# Check out +# https://stackoverflow.com/questions/653368/how-to-create-a-decorator-that-can-be-used-either-with-or-without-parameters +# for more on decorators with and without input arguments... + +# The decorator stuff should really move the into the pqthreads package + +def plottero(**options): + """ Decorator for end user functions, adding figure functionality""" + if len(options) > 0: + config.options.set_options(**options) + def wrap(func): + """ Wrapper """ + def func_wrapper(*args, **kwargs): + """ Wrapper """ + gui_agency = GUIAgency(worker=func, *args, **kwargs) + gui_agency.worker_agency.add_container('figure', workers.FigureWorker) + gui_agency.worker_agency.add_container('axis', workers.AxisWorker) + gui_agency.kickoff() + return gui_agency.result + return func_wrapper + return wrap diff --git a/mlpyqtgraph/axes.py b/mlpyqtgraph/axes.py index 2a9ab25..82479d5 100644 --- a/mlpyqtgraph/axes.py +++ b/mlpyqtgraph/axes.py @@ -4,7 +4,7 @@ import math -import pyqtgraph.Qt.QtCore as QtCore +from pyqtgraph.Qt import QtCore import pyqtgraph as pg import pyqtgraph.opengl as gl import pyqtgraph.functions as fn @@ -12,7 +12,7 @@ import numpy as np import mlpyqtgraph.config_options as config -import mlpyqtgraph.colors as colors +from mlpyqtgraph import colors class RootException(Exception): @@ -43,7 +43,8 @@ class Axis2D(pg.PlotItem): line_colors = colors_defs.get_line_colors() scale_box_line_color = colors_defs.get_scale_box_colors(part='line') scale_box_fill_color = colors_defs.get_scale_box_colors(part='fill') - def __init__(self, index, parent=None, **kwargs): + def __init__(self, index, **kwargs): + parent = kwargs.pop('parent', None) super().__init__(parent=parent, **kwargs) self.index = index self.setup() diff --git a/mlpyqtgraph/controllers.py b/mlpyqtgraph/controllers.py deleted file mode 100644 index 2d16519..0000000 --- a/mlpyqtgraph/controllers.py +++ /dev/null @@ -1,176 +0,0 @@ -""" -Module for controlling of and communication between worker and GUI thread - -""" -import sys -import pyqtgraph.Qt.QtWidgets as QtWidgets -import pyqtgraph.Qt.QtCore as QtCore - -import mlpyqtgraph.config_options as config -import mlpyqtgraph.windows as windows -import mlpyqtgraph.axes as axes -import mlpyqtgraph.thread_communicator as tc - - -class RootException(Exception): - """ Root Exception of the threads module """ - - -class SlotException(RootException): - """ This Exception is raised if no signal was sent to a slot """ - - -class WorkerException(RootException): - """ This Exception is raised if an error was raised in the worker thread """ - - -class GUIItemException(RootException): - """ This Exception is raised if an error was raised in the worker thread """ - - -class WorkerController(QtCore.QObject): - """ Controller class, to be used in the worker thread """ - error = QtCore.Signal() - - def __init__(self, parent=None): - super().__init__(parent) - self.figure_sender = tc.Sender('figure') - self.axis_sender = tc.Sender('axis') - - -worker_controller = WorkerController() - - -class GUIItemContainer(QtCore.QObject): - """ - Controller for a container with instances of user-supplied GUI element class - """ - - def __init__(self, item_class, parent=None): - super().__init__(parent) - self.items = list() - self.item_class = item_class - - def __repr__(self): - return f'GUIItemContainer(item_class={self.item_class.__name__})' - - @property - def count(self): - """ Current number of items """ - return len(self.items) - - def back(self): - """ Returns the last item """ - return self.items[-1] - - def create(self, args, kwargs): - """ Creates a new item instance with user-supplied item class """ - index = self.count - new_item = self.item_class(index, *args, **kwargs) - self.items.append(new_item) - return index - - def request(self, index, args): - """ Returns values of item attributes """ - item = self.items[index] - return [getattr(item, arg) for arg in args] - - def modify(self, index, kwargs): - """ Modifies item's attributes""" - item = self.items[index] - for key, value in kwargs.items(): - setattr(item, key, value) - - def method(self, index, func_name, args, kwargs): - """ Execute method on item """ - try: - item = self.items[index] - except IndexError as err: - raise GUIItemException(f'Index not find for {self.item_class} items') from err - func = getattr(item, func_name) - return func(*args, **kwargs) - - def delete(self, index): - """ Deletes the item at index """ - item = self.items.pop(index) - item.delete() - del item - - -class GUIItemFactory: - """ Factory for GUI Item instances coordinate by a GUIItemContainer """ - def __init__(self, container): - self.container = container - - def produce(self, *args, **kwargs): - """ Produce a new item """ - index = self.container.create(args, kwargs) - item = self.container.back() - return index, item - - -class FunctionRunnable(QtCore.QRunnable): - """ - QRunnable subclass that runs a user-supplied functions with exception - handling - """ - def __init__(self, worker_function, parent=None): - super().__init__(parent) - self.controller = worker_controller - self.worker_function = worker_function - - def run(self): - try: - self.worker_function() - except tc.ReceiverException: - pass # already handled ad receiver side - except BaseException: - (exception_type, value, traceback) = sys.exc_info() - sys.excepthook(exception_type, value, traceback) - self.controller.error.emit() - - -class GUIController(QtCore.QObject): - """ Controller class which coordinates all figure and axis objects """ - def __init__(self, worker, **kwargs): - super().__init__(kwargs.get('parent')) - self.exception_raised = False - self.application = QtWidgets.QApplication(sys.argv) - self.threadpool = QtCore.QThreadPool() - config.options.set_options(**kwargs) - self.setup_controllers() - self.execute(worker) - - def setup_controllers(self): - """ Setup all controllers """ - self.axis_container = GUIItemContainer(axes.factory) - FigureWindow = windows.FigureWindow - FigureWindow.axis_factory = GUIItemFactory(self.axis_container) - self.figure_container = GUIItemContainer(FigureWindow) - self.axis_receiver = tc.Receiver(self.axis_container) - self.figure_receiver = tc.Receiver(self.figure_container) - - def execute(self, worker): - """ Create QApplication, start worker thread and the main event loop """ - self.start(worker) - try: - self.application.exec_() - except tc.SenderException: - if not self.exception_raised: - raise - self.exception_raised = False - finally: - self.application.exit() - - def start(self, worker): - """ Starts the worker thread with driver function """ - runnable = FunctionRunnable(worker) - runnable.controller.error.connect(self.worker_exception) - runnable.controller.figure_sender.connect_receiver(self.figure_receiver) - runnable.controller.axis_sender.connect_receiver(self.axis_receiver) - self.threadpool.start(runnable) - - @QtCore.Slot() - def worker_exception(self): - """ Slot to react on a work exception """ - self.exception_raised = True diff --git a/mlpyqtgraph/descriptors.py b/mlpyqtgraph/descriptors.py deleted file mode 100644 index 010b4da..0000000 --- a/mlpyqtgraph/descriptors.py +++ /dev/null @@ -1,111 +0,0 @@ -""" -Descriptors for attributes and methods for work thread classes in mlpyqtgraph - -""" - - -class AbstractDescriptor: - """ - Abstract descriptor class that serves as base class for Descriptors - """ - controller = None - - @classmethod - def with_controller(cls, controller): - """ Define a Descriptor class with defined controller """ - cls.controller = controller - return cls - - def __init__(self): - self.name = None - - def __set_name__(self, owner, name): - self.name = name - - def __get__(self, obj, owner=None): - raise NotImplementedError - - def __repr__(self): - return f'Descriptor(controller={repr(self.controller)}' - - -class AttributeDescriptor(AbstractDescriptor): - """ - Custom descriptor for setting and getting class attributes in the main event - loop - - This descriptor enables setting and getting object instance attributes using - a controller. It expects the presence of a controller object and index on - its parent object instance. - """ - def __get__(self, obj, owner=None): - return self.controller.request(obj.index, self.name)[0] - - def __set__(self, obj, value): - kwargs = {self.name: value} - self.controller.modify(obj.index, **kwargs) - - def __repr__(self): - return 'Attribute' + super().__repr__() - - -class MethodDescriptor(AbstractDescriptor): - """ - Custom descriptor for calling methods in the main event loop - - This descriptor enables calling method of an object instance using a custom - controller. It expects the presence of a controller object and index on its - parent object instance. - """ - def __init__(self): - super().__init__() - self.index = None - - def __get__(self, obj, owner=None): - try: - self.index = obj.index - except AttributeError: - pass - return self - - def __call__(self, *args, **kwargs): - return self.controller.method(self.index, self.name, *args, **kwargs) - - def __repr__(self): - return 'Method' + super().__repr__() - - - -class DescriptorFactory: - """ - Factory for attribute and method descriptors that correlate to attributes and methods of a class - in another thread. Interaction is organized through a controller, which is required by the - attribute and method descriptors. - - Note that we need to copy the classes AttributeDescriptor and MethodDescriptor (using - inheritance) to avoid controller clashes with other factory instances. - """ - def __init__(self, controller): - self.controller = controller - self.attribute_descriptor_class = self.copy_class(AttributeDescriptor) - self.method_descriptor_class = self.copy_class(MethodDescriptor) - - @staticmethod - def copy_class(original_class): - """ Creates a copy of a class using inheritance """ - class ClassCopy(original_class): - """ Copy of Class original_class """ - return ClassCopy - - def __repr__(self): - return f'DescriptorFactory(controller={self.controller})' - - @property - def attribute(self): - """ Produce attribute descriptor class """ - return self.attribute_descriptor_class.with_controller(self.controller) - - @property - def method(self): - """ Produce attribute descriptor class """ - return self.method_descriptor_class.with_controller(self.controller) diff --git a/mlpyqtgraph/ml_functions.py b/mlpyqtgraph/ml_functions.py index 1314312..611202a 100644 --- a/mlpyqtgraph/ml_functions.py +++ b/mlpyqtgraph/ml_functions.py @@ -2,28 +2,31 @@ Matplotlib-like functions for easy figure and plot definitions """ -import mlpyqtgraph.worker as worker +from pqthreads import controllers def figure(*args, **kwargs): """ Create, raise or modify FigureWorker objects """ + container = controllers.worker_refs.get('figure') if not args: - return worker.figures_container.create(**kwargs) + return container.create(**kwargs) figure_worker = args[0] figure_worker.activate() - worker.figures_container.current = figure_worker + container.current = figure_worker return figure_worker def gcf(): """ Returns the current figure """ - return worker.figures_container.current + container = controllers.worker_refs.get('figure') + return container.current def gca(): """ Returns the current axis """ - if worker.axes_container.current is None: + container = controllers.worker_refs.get('axis') + if container.current is None: figure() # make sure we always have a figure - return worker.axes_container.current + return container.current def close(figure_ref): diff --git a/mlpyqtgraph/thread_communicator.py b/mlpyqtgraph/thread_communicator.py deleted file mode 100644 index 8c8639f..0000000 --- a/mlpyqtgraph/thread_communicator.py +++ /dev/null @@ -1,164 +0,0 @@ -""" -Thread communicator module for sending and receiving information from one thread to another using a -sender and receiver -""" - - -from copy import copy -from contextlib import contextmanager -import pyqtgraph.Qt.QtCore as QtCore - - -class RootTCException(Exception): - """ Root Exception of the threads module """ - - -class SenderException(RootTCException): - """ This Exception is raised if no signal was sent to a slot and thus no message was received""" - - -class ReceiverException(RootTCException): - """ This Exception is raised if an error at receiver side was detected """ - - -@contextmanager -def wait_signal(signal, timeout=1000): - """Block loop until signal emitted, or timeout (ms) elapses.""" - loop = QtCore.QEventLoop() - signal.connect(loop.quit) - yield - if timeout is not None: - QtCore.QTimer.singleShot(timeout, loop.quit) - loop.exec_() - - -class Sender(QtCore.QObject): - """ Enables exchange of data with Receiver using signal/slots """ - createSignal = QtCore.Signal(list, dict) - modifySignal = QtCore.Signal(int, dict) - requestSignal = QtCore.Signal(int, list) - methodSignal = QtCore.Signal(int, str, list, dict) - deleteSignal = QtCore.Signal(int) - - dataRecevied = QtCore.Signal() - - def __init__(self, name, parent=None): - super().__init__(parent) - self.no_message = object() - self.receiver_error = False - self.message = self.no_message - self.name = name - - def __repr__(self): - return f'Sender(name={self.name})' - - def connect_receiver(self, receiver): - """ Connects the sender to a receiver """ - receiver.signal.connect(self.slot) - receiver.error.connect(self.error_detected) - self.createSignal.connect(receiver.create_slot) - self.modifySignal.connect(receiver.modify_slot) - self.requestSignal.connect(receiver.request_slot) - self.methodSignal.connect(receiver.method_slot) - self.deleteSignal.connect(receiver.delete_slot) - - def create(self, *args, **kwargs): - """ Send out a signal and obtain data from receiver""" - with wait_signal(self.dataRecevied): - self.createSignal.emit(args, kwargs) - return self.read_message() - - def modify(self, index, **kwargs): - """ Send out a one-way signal with given arguments and keyword arguments """ - self.modifySignal.emit(index, kwargs) - - def request(self, index, *args): - """ Obtain data from receiver""" - with wait_signal(self.dataRecevied): - self.requestSignal.emit(index, args) - return self.read_message() - - def method(self, index, func_name, *args, **kwargs): - """ Send out a signal to execute a method on the receiver class """ - with wait_signal(self.dataRecevied): - self.methodSignal.emit(index, func_name, args, kwargs) - return self.read_message() - - def delete(self, index): - """ Send out a signal to delete object at index on the receiver class """ - self.closeSignal.emit(index) - - def read_message(self): - """ Helper method, that reads message set by slot and returns a copy """ - if self.message is self.no_message: - if self.receiver_error: - raise ReceiverException('Error detected at receiver side') - raise SenderException('No message received') - message = copy(self.message) - self.message = self.no_message - return message - - @QtCore.Slot(dict) - def slot(self, data): - """ Slot for receiving data """ - self.message = data - self.dataRecevied.emit() - - def error_detected(self): - """ If a receiver error is detected ... """ - self.receiver_error = True - - -class Receiver(QtCore.QObject): - """ A receiver for operations whose instructions were sent by Sender """ - signal = QtCore.Signal(dict) - error = QtCore.Signal() - - def __init__(self, controller, parent=None): - super().__init__(parent) - self.controller = controller - self.create = controller.create - self.modify = controller.modify - self.request = controller.request - self.method = controller.method - self.delete = controller.delete - - def __repr__(self): - return f'Receiver(controller={self.controller})' - - @contextmanager - def register_exception(self): - """ Register errors at receiver side """ - try: - yield - except BaseException: - self.error.emit() - raise - - @QtCore.Slot(list, dict) - def create_slot(self, args, kwargs): - """ Slot for creating a new class instance """ - with self.register_exception(): - self.signal.emit(self.create(args, kwargs)) - - @QtCore.Slot(int, dict) - def modify_slot(self, index, kwargs): - """ Slot for modifying an instance attribute """ - self.modify(index, kwargs) - - @QtCore.Slot(int, list) - def request_slot(self, index, args): - """ Slot for requesting an instance attribute """ - with self.register_exception(): - self.signal.emit(self.request(index, args)) - - @QtCore.Slot(int, str, list, dict) - def method_slot(self, index, func, args, kwargs): - """ Slot for calling a class instance method """ - with self.register_exception(): - self.signal.emit(self.method(index, func, args, kwargs)) - - @QtCore.Slot(int) - def delete_slot(self, index): - """ Slot for closing/deleting a class instance object at index """ - self.delete(index) diff --git a/mlpyqtgraph/windows.py b/mlpyqtgraph/windows.py index 3515466..c68d668 100644 --- a/mlpyqtgraph/windows.py +++ b/mlpyqtgraph/windows.py @@ -4,10 +4,12 @@ """ import sys - -import pyqtgraph.Qt.QtWidgets as QtWidgets -import pyqtgraph.Qt.QtCore as QtCore +from pyqtgraph.Qt import QtWidgets +from pyqtgraph.Qt import QtCore import pyqtgraph as pg +from pqthreads import controllers + + pg.setConfigOption('background', 'w') pg.setConfigOption('foreground', 'k') pg.setConfigOptions(antialias=True) @@ -85,14 +87,12 @@ def change_layout(self, layout_type='pg'): self.window.setCentralWidget(LayoutWidget()) return True - def create_axis(self, row=0, column=0, row_span=1, column_span=1, **kwargs): + def create_axis(self, index, row=0, column=0, row_span=1, column_span=1): """ Creates an axis and return its index """ - if existing_axis := self.graphics_layout.getItem(row, column): - return existing_axis.index - kwargs['axis_type'] = self.axis_type - index, axis = self.axis_factory.produce(**kwargs) + if self.graphics_layout.getItem(row, column): + return + axis = controllers.gui_refs.get('axis').items[index] self.graphics_layout.addItem(axis, row, column, row_span, column_span) - return index @property def graphics_layout(self): diff --git a/mlpyqtgraph/worker.py b/mlpyqtgraph/worker.py deleted file mode 100644 index fdd2e84..0000000 --- a/mlpyqtgraph/worker.py +++ /dev/null @@ -1,148 +0,0 @@ -""" -This modules defines all worker thread related classes and instances - -""" -import weakref -import mlpyqtgraph.controllers as controllers -import mlpyqtgraph.descriptors as descr - - -class AxisWorker: - """ Worker thread axis to Control AxisWidget on the GUI thread """ - descriptor_factory = descr.DescriptorFactory(controllers.worker_controller.axis_sender) - row = descriptor_factory.attribute() - column = descriptor_factory.attribute() - add = descriptor_factory.method() - add_legend = descriptor_factory.method() - grid = descriptor_factory.attribute() - xlim = descriptor_factory.attribute() - ylim = descriptor_factory.attribute() - xlabel = descriptor_factory.attribute() - ylabel = descriptor_factory.attribute() - xticks = descriptor_factory.attribute() - yticks = descriptor_factory.attribute() - set_xticks = descriptor_factory.method() - set_yticks = descriptor_factory.method() - - def __init__(self, index): - self.index = index - - def __repr__(self): - return f'AxisWorker(index={self.index})' - - -class AxesContainer: - """ Container for Axis """ - def __init__(self): - self.axes = list() - self.current = None - - def __repr__(self): - repr_string = '[' - for idx, axis in enumerate(self.axes): - if idx > 0: - repr_string += ', ' - repr_string += repr(axis) - repr_string += ']' - return repr_string - - def create(self, index, *args, **kwargs): - """ Create a new FigureWorker and return a weak reference proxy """ - self.append(AxisWorker(index, *args, **kwargs)) - self.current = self.back() - return weakref.proxy(self.back()) - - def append(self, item): - """ Append an item """ - self.axes.append(item) - - def back(self): - """ Returns the last item """ - return self.axes[-1] - - -axes_container = AxesContainer() - - -class FigureWorker: - """ Worker thread figure to control FigureWindow on the GUI thread""" - controller = controllers.worker_controller.figure_sender - descriptor_factory = descr.DescriptorFactory(controller) - width = descriptor_factory.attribute() - height = descriptor_factory.attribute() - raise_window = descriptor_factory.method() - create_axis = descriptor_factory.method() - change_layout = descriptor_factory.method() - change_axis = descriptor_factory.method() - - def __init__(self, *args, **kwargs): - self.axes = list() - self.index = self.controller.create(*args, **kwargs) - self.add_axis() - - def __repr__(self): - return f'FigureWorker(index={self.index})' - - def activate(self): - """ Sets this figure a current figure and raises it to top """ - self.raise_window() - - def close(self): - """ Closes the current figure on the GUI side """ - self.controller.delete(self.index) - - def add_axis(self, *args, **kwargs): - """ Adds an axis to the figure worker """ - axis_index = self.create_axis(*args, **kwargs) - axis = axes_container.create(axis_index) - self.axes.append(axis) - - -class FiguresContainer: - """ Container for FigureWorkers """ - def __init__(self): - self.figures = list() - self.current = None - - def __repr__(self): - repr_string = '[' - for idx, figure in enumerate(self.figures): - if idx > 0: - repr_string += ', ' - repr_string += repr(figure) - repr_string += ']' - return repr_string - - def create(self, *args, **kwargs): - """ Create a new FigureWorker and return a weak reference proxy """ - self.append(FigureWorker(*args, **kwargs)) - self.current = self.back() - return weakref.proxy(self.back()) - - def append(self, item): - """ Append an item """ - self.figures.append(item) - - def back(self): - """ Returns the last item """ - return self.figures[-1] - - def close(self, item): - """ - Remove an item from the container, closes its figure and deletes it. - After calling this function, weak references to this item will no longer - be valid. - """ - index = self.figures.index(item) - figure_worker = self.figures.pop(index) - figure_worker.close() - del figure_worker - - -figures_container = FiguresContainer() - - -class PlotWidgetWorker: - """ PlotWidget item for the worker thread """ - def __init__(self): - pass diff --git a/mlpyqtgraph/workers.py b/mlpyqtgraph/workers.py new file mode 100644 index 0000000..ddbc958 --- /dev/null +++ b/mlpyqtgraph/workers.py @@ -0,0 +1,47 @@ +""" +This modules defines all worker thread related classes and instances +""" + +from pqthreads import controllers +from pqthreads import containers + + +class AxisWorker(containers.WorkerItem): + """ Worker thread axis to Control AxisWidget on the GUI thread """ + factory = containers.WorkerItem.get_factory() + row = factory.attribute() + column = factory.attribute() + add = factory.method() + add_legend = factory.method() + grid = factory.attribute() + xlim = factory.attribute() + ylim = factory.attribute() + xlabel = factory.attribute() + ylabel = factory.attribute() + xticks = factory.attribute() + yticks = factory.attribute() + set_xticks = factory.method() + set_yticks = factory.method() + + +class FigureWorker(containers.WorkerItem): + """ Worker thread figure to control FigureWindow on the GUI thread""" + factory = containers.WorkerItem.get_factory() + width = factory.attribute() + height = factory.attribute() + raise_window = factory.method() + create_axis = factory.method() + change_layout = factory.method() + change_axis = factory.method() + + def __init__(self, *args, **kwargs): + self.axes = [] + super().__init__(*args, **kwargs) + self.add_axis() + + def add_axis(self, *args, **kwargs): + """ Adds an axis to the figure worker """ + axis_container = controllers.worker_refs.get('axis') + axis = axis_container.create(**kwargs) + self.create_axis(axis.index, **kwargs) + self.axes.append(axis.index) diff --git a/requirements.txt b/requirements.txt index 1e97953..62efd26 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ +pqthreads pyqtgraph pyopengl From e915ec495cd1db5bcc4300bf3cd07cb4fa1c5579 Mon Sep 17 00:00:00 2001 From: Sietze van Buuren Date: Sun, 23 Jun 2024 20:32:28 +0200 Subject: [PATCH 2/4] General Axis class introduced to create Axis2D and Axis3D classes and subplot functionality removed Signed-off-by: Sietze van Buuren --- examples/subplot.py | 28 ---------------------------- mlpyqtgraph/__init__.py | 4 +++- mlpyqtgraph/axes.py | 29 +++++++++++++++++++---------- mlpyqtgraph/ml_functions.py | 8 +------- mlpyqtgraph/windows.py | 13 +------------ mlpyqtgraph/workers.py | 14 +++++++++----- 6 files changed, 33 insertions(+), 63 deletions(-) delete mode 100644 examples/subplot.py diff --git a/examples/subplot.py b/examples/subplot.py deleted file mode 100644 index af716c5..0000000 --- a/examples/subplot.py +++ /dev/null @@ -1,28 +0,0 @@ -""" -Basic example that shows subplot usage of mlpyqtgraph -""" - -import numpy as np -import mlpyqtgraph as mpg - - -@mpg.plotter -def main(): - """ Example with subplots """ - fig = mpg.figure(title='Third figure') - fig.width = 540 - fig.height = 440 - mpg.subplot(0,0) - theta = np.linspace(0, 2*np.pi, 100) - mpg.plot(theta, np.sin(theta)) - mpg.gca().grid = True - mpg.subplot(0,1) - mpg.plot(theta, np.cos(theta)) - mpg.subplot(1,0) - mpg.plot(theta, np.tan(theta)) - mpg.subplot(1,1) - mpg.plot(theta, np.arctan(theta)) - - -if __name__ == '__main__': - main() diff --git a/mlpyqtgraph/__init__.py b/mlpyqtgraph/__init__.py index b42ad37..09a12ab 100644 --- a/mlpyqtgraph/__init__.py +++ b/mlpyqtgraph/__init__.py @@ -15,7 +15,7 @@ GUIAgency = controllers.GUIAgency GUIAgency.add_agent('figure', windows.FigureWindow) -GUIAgency.add_agent('axis', axes.Axis2D) +GUIAgency.add_agent('axis', axes.Axis) def plotter(func): @@ -36,6 +36,8 @@ def func_wrapper(*args, **kwargs): # The decorator stuff should really move the into the pqthreads package +# What to do, if a decorators is used, but no plot commands are issued? + def plottero(**options): """ Decorator for end user functions, adding figure functionality""" if len(options) > 0: diff --git a/mlpyqtgraph/axes.py b/mlpyqtgraph/axes.py index 82479d5..822f540 100644 --- a/mlpyqtgraph/axes.py +++ b/mlpyqtgraph/axes.py @@ -23,18 +23,9 @@ class InvalidAxis(RootException): """ Exception raised for invalid axes """ -def factory(*args, **kwargs): - """ Factory for creating 2D or 3D Axis objects """ - axis_type = kwargs.pop('axis_type') - if axis_type == '2D': - return Axis2D(*args, **kwargs) - if axis_type == '3D': - return Axis3D(*args, **kwargs) - raise InvalidAxis(f'Invalid Axis Type: {axis_type}. Should be either 2D or 3D') - - class Axis2D(pg.PlotItem): """ Axis for plots in a given figure layout """ + axis_type = '2D' pen_styles = {'-': QtCore.Qt.SolidLine, '--': QtCore.Qt.DashLine, ':': QtCore.Qt.DotLine, @@ -277,9 +268,13 @@ def get_ticks(self, axis): ticks.extend(values) return sorted(ticks) + def delete(self): + """ Closes the axis """ + class Axis3D(gl.GLViewWidget): """ 3D axis """ + axis_type = '3D' glOption = { ogl.GL_DEPTH_TEST: True, @@ -391,3 +386,17 @@ def add_single_grid_line(self, x, y, z): """ Plots a single grid line for given coordinates """ points = np.column_stack((x, y, z)) self.addItem(gl.GLLinePlotItem(pos=points, **self.default_line_options)) + + def delete(self): + """ Closes the axis """ + + +class Axis: + """ General Axis class, creates either 2D or 3D axis """ + def __new__(cls, *args, **kwargs): + axis_type = kwargs.pop('axis_type', '2D') + if axis_type == '2D': + return Axis2D(*args, **kwargs) + if axis_type == '3D': + return Axis3D(*args, **kwargs) + raise InvalidAxis(f'Invalid Axis Type: {axis_type}. Should be either 2D or 3D') diff --git a/mlpyqtgraph/ml_functions.py b/mlpyqtgraph/ml_functions.py index 611202a..11bd3f1 100644 --- a/mlpyqtgraph/ml_functions.py +++ b/mlpyqtgraph/ml_functions.py @@ -39,11 +39,6 @@ def plot(*args, **kwargs): return gca().add(*args, **kwargs) -def subplot(*args, **kwargs): - """ Create subplot and return its axis""" - return gcf().add_axis(*args, **kwargs) - - def legend(*args): """ Adds a legend to the current figure """ gca().add_legend(*args) @@ -52,6 +47,5 @@ def legend(*args): def surf(*args, **kwargs): """ Plots a 3D surface """ if gcf().change_layout('Qt'): - gcf().change_axis('3D') - gcf().add_axis() + gcf().add_axis(axis_type='3D') gca().add(*args, **kwargs) diff --git a/mlpyqtgraph/windows.py b/mlpyqtgraph/windows.py index c68d668..783af4c 100644 --- a/mlpyqtgraph/windows.py +++ b/mlpyqtgraph/windows.py @@ -52,7 +52,6 @@ def __init__(self, index, title='Figure', width=600, height=500, parent=None): super().__init__(parent=parent) self.index = index self.layout_type = 'None' - self.axis_type = '2D' self.window = self.setup_window(parent, width, height) self.change_layout() self.title = f'Figure {index+1}: {title}' @@ -64,14 +63,6 @@ def setup_window(self, parent, width, height): window.resize(width, height) return window - def change_axis(self, axis_type='2D'): - """ - Change the figure's axis type; 2D for Axis2D or 3D for Axis3D - - Returns: boolean indicating layout change - """ - self.axis_type = axis_type - def change_layout(self, layout_type='pg'): """ Change the figure's layout type; pg for pyqtgraph's native layout or Qt layout @@ -87,10 +78,8 @@ def change_layout(self, layout_type='pg'): self.window.setCentralWidget(LayoutWidget()) return True - def create_axis(self, index, row=0, column=0, row_span=1, column_span=1): + def set_axis(self, index, row=0, column=0, row_span=1, column_span=1): """ Creates an axis and return its index """ - if self.graphics_layout.getItem(row, column): - return axis = controllers.gui_refs.get('axis').items[index] self.graphics_layout.addItem(axis, row, column, row_span, column_span) diff --git a/mlpyqtgraph/workers.py b/mlpyqtgraph/workers.py index ddbc958..6c0b543 100644 --- a/mlpyqtgraph/workers.py +++ b/mlpyqtgraph/workers.py @@ -30,18 +30,22 @@ class FigureWorker(containers.WorkerItem): width = factory.attribute() height = factory.attribute() raise_window = factory.method() - create_axis = factory.method() change_layout = factory.method() - change_axis = factory.method() + set_axis = factory.method() def __init__(self, *args, **kwargs): - self.axes = [] + self.axis = None super().__init__(*args, **kwargs) self.add_axis() def add_axis(self, *args, **kwargs): """ Adds an axis to the figure worker """ axis_container = controllers.worker_refs.get('axis') + if self.axis: + remove_axis = self.axis + self.axis = None + axis_container.close(remove_axis) axis = axis_container.create(**kwargs) - self.create_axis(axis.index, **kwargs) - self.axes.append(axis.index) + index = axis.index + self.set_axis(index) + self.axis = axis From a2c717b754b529272506d9a6f20036fdbf02befc Mon Sep 17 00:00:00 2001 From: Sietze van Buuren Date: Sun, 30 Jun 2024 20:20:45 +0200 Subject: [PATCH 3/4] Working newer setup without subplots Signed-off-by: Sietze van Buuren --- mlpyqtgraph/ml_functions.py | 5 +++-- mlpyqtgraph/windows.py | 12 ++++++++---- mlpyqtgraph/workers.py | 14 ++++++-------- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/mlpyqtgraph/ml_functions.py b/mlpyqtgraph/ml_functions.py index 11bd3f1..7e4a591 100644 --- a/mlpyqtgraph/ml_functions.py +++ b/mlpyqtgraph/ml_functions.py @@ -36,6 +36,7 @@ def close(figure_ref): def plot(*args, **kwargs): """ Plots into the current axis """ + gcf().create_axis(axis_type='2D') return gca().add(*args, **kwargs) @@ -46,6 +47,6 @@ def legend(*args): def surf(*args, **kwargs): """ Plots a 3D surface """ - if gcf().change_layout('Qt'): - gcf().add_axis(axis_type='3D') + gcf().change_layout('Qt') + gcf().create_axis(axis_type='3D') gca().add(*args, **kwargs) diff --git a/mlpyqtgraph/windows.py b/mlpyqtgraph/windows.py index 783af4c..56f8328 100644 --- a/mlpyqtgraph/windows.py +++ b/mlpyqtgraph/windows.py @@ -38,10 +38,14 @@ def getItem(self, row, column): """ Returns the item at row, col. If empty return None """ return self.layout().itemAtPosition(row, column) - def addItem(self, item, row, column, rowSpan=1, columnSpan=1): + def addItem(self, item, row=0, column=0, rowSpan=1, columnSpan=1): """ Adds an item at row, col with rowSpand and colSpan """ self.layout().addWidget(item, row, column, rowSpan, columnSpan) + def removeItem(self, item): + """ Removes an item from the layout """ + self.layout().removeItem(item) + class FigureWindow(QtCore.QObject): """ Controls a figure window instance """ @@ -78,10 +82,10 @@ def change_layout(self, layout_type='pg'): self.window.setCentralWidget(LayoutWidget()) return True - def set_axis(self, index, row=0, column=0, row_span=1, column_span=1): - """ Creates an axis and return its index """ + def add_axis(self, index): + """ Adds an axis to the figure """ axis = controllers.gui_refs.get('axis').items[index] - self.graphics_layout.addItem(axis, row, column, row_span, column_span) + self.graphics_layout.addItem(axis) @property def graphics_layout(self): diff --git a/mlpyqtgraph/workers.py b/mlpyqtgraph/workers.py index 6c0b543..20a3a5a 100644 --- a/mlpyqtgraph/workers.py +++ b/mlpyqtgraph/workers.py @@ -31,21 +31,19 @@ class FigureWorker(containers.WorkerItem): height = factory.attribute() raise_window = factory.method() change_layout = factory.method() - set_axis = factory.method() + add_axis = factory.method() + has_axis = factory.method() def __init__(self, *args, **kwargs): self.axis = None super().__init__(*args, **kwargs) - self.add_axis() - def add_axis(self, *args, **kwargs): + def create_axis(self, *args, **kwargs): """ Adds an axis to the figure worker """ - axis_container = controllers.worker_refs.get('axis') if self.axis: - remove_axis = self.axis - self.axis = None - axis_container.close(remove_axis) + return + axis_container = controllers.worker_refs.get('axis') axis = axis_container.create(**kwargs) index = axis.index - self.set_axis(index) + self.add_axis(index) self.axis = axis From 0822832ab80d03b21d2c1d632c29e85403c75714 Mon Sep 17 00:00:00 2001 From: Sietze van Buuren Date: Sun, 30 Jun 2024 20:22:29 +0200 Subject: [PATCH 4/4] Make a figure is always available Signed-off-by: Sietze van Buuren --- mlpyqtgraph/ml_functions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mlpyqtgraph/ml_functions.py b/mlpyqtgraph/ml_functions.py index 7e4a591..8a648fc 100644 --- a/mlpyqtgraph/ml_functions.py +++ b/mlpyqtgraph/ml_functions.py @@ -18,6 +18,8 @@ def figure(*args, **kwargs): def gcf(): """ Returns the current figure """ container = controllers.worker_refs.get('figure') + if container.current is None: + figure() # make sure we always have a figure return container.current