-
Notifications
You must be signed in to change notification settings - Fork 0
Plugins
Plugins add custom functionality to FigureForge and can be used to semi-automate tedious, common tasks, or otherwise implement functionality which is not yet available in the application. Plugins take the form of a Python class that provides a few attributes and a run() method. The requirements are intentionally minimal to permit the developer to get creative with their own implementations.
Plugins must be saved in Python files (.py) in the FigureForge\plugins\ directory. For instance, we will create a demo plugin in the FigureForge\plugins\example.py which contains the ExamplePlugin class. The module name (e.g., example in this case) is not important to FigureForge.
class ExamplePlugin():
name = "Remove Spines" # Required
tooltip = "Remove the top and right spines from the selected axes." # Optional
icon = "FigureForge/plugings/remove_spines.png" # Optional
submenu = "Spines" # Optional
def run(self, obj): # Required
obj.spines[["top", "right"]].set_visible(False)
print(f"Removed top and right spines from {obj}.")This example illustrates essentially the bare minimum characteristics required of the plugin. The plugin must supply a name attribute which is the str that appears in the plugins menu, and may optionally provide a tooltip which displays on hover, an icon filepath to display in the menu, and a submenu string. Otherwise, the class must also provide a run(self, obj) method which is called when the plugin is triggered. The obj parameter is the currently selected object in the Figure Explorer. E.g., if an Axes is selected, then the matplotlib.axes.Axes instance will be provided to run().
The example plugin herein simply hides the top and right spines. Therefore, it only works when an matplotlib.axes.Axes instance is provided. No provisions are made by FigureForge to catch exceptions when an unexpected object type is supplied to the plugin; that logic must be handled within the plugin. Right now, if a Figure object is selected, our plugin will raise an exception.
Consider, in our example plugin, that we want to expand its functionality to also remove spines whether a matplotlib.figure.Figure instance is provided or also if a matplotlib.spines.Spine instance is provided. We can modify our plugin as follows:
from matplotlib.figure import Figure
from matplotlib.axes import Axes
from matplotlib.spines import Spine
class ExamplePlugin():
name = "Remove Spines"
tooltip = "Remove the top and right spines from the selected axes."
icon = "FigureForge/plugins/remove_spines.png"
submenu = "Spines"
def run(self, obj):
if isinstance(obj, Figure):
for ax in obj.axes:
ax.spines[["top", "right"]].set_visible(False)
elif isinstance(obj, Axes):
obj.spines[["top", "right"]].set_visible(False)
elif isinstance(obj, Spine):
obj.set_visible(False)Now, we determine the type of object supplied to the plugin and act accordingly. If it is a Figure, then the spines will be removed from all axes in that figure. If it is an Axes, then only that axes will be affected. If it is a Spine, then that individual spine will be affected (regardless whether it is a top or right spine or not).
FigureForge uses PySide6 for the implementation of its interface. Therefore, QtWidgets can be used to conveniently provide means for user interaction. As a simple example, let's inform the user that the action was completed successfully or not. We will import the QMessageBox widget from Pyside6.QtWidgets and create a success and failure notification and then display the appropriate one:
from matplotlib.figure import Figure
from matplotlib.axes import Axes
from matplotlib.spines import Spine
from PySide6.QtWidgets import QMessageBox
class ExamplePlugin:
name = "Remove Spines"
tooltip = "Remove the top and right spines from the selected object."
icon = "FigureForge/plugins/remove_spines.png"
submenu = "Spines"
def run(self, obj):
msg_success = QMessageBox()
msg_success.setText("Spines removed.")
msg_success.setIcon(QMessageBox.Information)
if isinstance(obj, Figure):
for ax in obj.axes:
ax.spines[["top", "right"]].set_visible(False)
msg_success.exec()
elif isinstance(obj, Axes):
obj.spines[["top", "right"]].set_visible(False)
msg_success.exec()
elif isinstance(obj, Spine):
obj.set_visible(False)
msg_success.exec()
else:
msg_failure = QMessageBox()
msg_failure.setText(f"Invalid object type: {type(obj)}.")
msg_failure.setIcon(QMessageBox.Warning)
msg_failure.exec()Now, a success or failure dialog will be displayed when the plugin is executed. Note: the displayed figure is not updated until after the plugin's execution is completed. Therefore, the message box confirming success will be displayed while the spines are still present. However, as soon as the message box is closed, the figure will be updated showing how the spines were removed.
Now, let's incorporate some user interaction into the plugin. We will accomplish this by prompting the user to select which of the spines to hide with a dialog comprising checkboxes for each of the spine locations. Within our ExamplePlugin class, we will define another class called SelectSpinesDialog:
from PySide6.QtWidgets import QMessageBox, QVBoxLayout, QCheckBox, QDialogButtonBox, QDialog
class ExamplePlugin:
...
class SelectSpinesDialog(QDialog):
def __init__(self):
super().__init__()
self.setWindowTitle("Select Spines to Remove")
self.layout = QVBoxLayout(self)
self.spines = [
"top",
"bottom",
"left",
"right",
]
self.checkboxes = []
for spine in self.spines:
checkbox = QCheckBox(spine)
self.checkboxes.append(checkbox)
self.layout.addWidget(checkbox)
self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.buttons.accepted.connect(self.accept)
self.buttons.rejected.connect(self.reject)
self.layout.addWidget(self.buttons)
def get_selected_spines(self):
return {
spine: checkbox.isChecked()
for spine, checkbox in zip(self.spines, self.checkboxes)
}This dialog will display four checkboxes corresponding with the four spine locations. By default, we also check the top and right spines for convenience. When the user presses "OK", a dictionary of each spine and a bool will be returned.
To incorporate this dialog into our plugin, we can use the following:
def run(self, obj):
msg_success = QMessageBox()
msg_success.setText("Spines removed successfully.")
msg_success.setIcon(QMessageBox.Information)
if isinstance(obj, Spine):
obj.set_visible(False)
msg_success.exec()
elif isinstance(obj, (Figure, Axes)):
dialog = self.SelectSpinesDialog()
if dialog.exec() == QDialog.Accepted:
selected_spines = dialog.get_selected_spines()
if isinstance(obj, Figure):
for ax in obj.axes:
for spine, remove in selected_spines.items():
if remove:
ax.spines[spine].set_visible(False)
elif isinstance(obj, Axes):
for spine, remove in selected_spines.items():
if remove:
obj.spines[spine].set_visible(False)
msg_success.exec()
else:
msg_failure = QMessageBox()
msg_failure.setText(f"Invalid object type: {type(obj)}.")
msg_failure.setIcon(QMessageBox.Warning)
msg_failure.exec()Let's talk through how this works. First, we check if the provided object is a Spine instance. If so, we turn of its visibility and complete execution since in the context of a single spine, there is no "top" or "bottom" or etc. Next, if the object is a Figure or Axes, then we prompt the user to select which spines to hide. If the user presses "Cancel" then no action is taken by the plugin. Otherwise, the selected spines are determined and correspondingly hidden in the axes or each axes in the figure depending on the type of object provided. Otherwise, a warning is displayed indicating that an incorrect type of object was supplied.
Putting it all together, this is the complete example plugin:
from matplotlib.figure import Figure
from matplotlib.axes import Axes
from matplotlib.spines import Spine
from PySide6.QtWidgets import QMessageBox, QVBoxLayout, QCheckBox, QDialogButtonBox, QDialog
class ExamplePlugin:
name = "Remove Spines"
tooltip = "Remove spines from the selected item."
icon = "FigureForge/plugins/remove_spines.png"
submenu = "Spines"
def run(self, obj):
msg_success = QMessageBox()
msg_success.setText("Spines removed successfully.")
msg_success.setIcon(QMessageBox.Information)
if isinstance(obj, Spine):
obj.set_visible(False)
msg_success.exec()
elif isinstance(obj, (Figure, Axes)):
dialog = self.SelectSpinesDialog()
if dialog.exec() == QDialog.Accepted:
selected_spines = dialog.get_selected_spines()
if isinstance(obj, Figure):
for ax in obj.axes:
for spine, remove in selected_spines.items():
if remove:
ax.spines[spine].set_visible(False)
elif isinstance(obj, Axes):
for spine, remove in selected_spines.items():
if remove:
obj.spines[spine].set_visible(False)
msg_success.exec()
else:
msg_failure = QMessageBox()
msg_failure.setText(f"Invalid object type: {type(obj)}.")
msg_failure.setIcon(QMessageBox.Warning)
msg_failure.exec()
class SelectSpinesDialog(QDialog):
def __init__(self):
super().__init__()
self.setWindowTitle("Select Spines to Remove")
self.layout = QVBoxLayout(self)
self.spines = [
"top",
"bottom",
"left",
"right",
]
self.checkboxes = []
for spine in self.spines:
checkbox = QCheckBox(spine)
self.checkboxes.append(checkbox)
self.layout.addWidget(checkbox)
self.checkboxes[0].setChecked(True)
self.checkboxes[3].setChecked(True)
self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.buttons.accepted.connect(self.accept)
self.buttons.rejected.connect(self.reject)
self.layout.addWidget(self.buttons)
def get_selected_spines(self):
return {
spine: checkbox.isChecked()
for spine, checkbox in zip(self.spines, self.checkboxes)
}If your plugin requires a module or package that is not already installed in your environment (or possibly if FigureForge is installed from binary), than you may need to add the package to the plugin requirements file. This file is located in FigureForge/plugins/plugin_requirements.txt and behaves just like a typical requirements.txt file used by pip. When FigureForge loads, and anytime the "reload plugins" action is used, FigureForge will read this file and install the listed packages.
A plugin can also import other plugins. For instance, you might want to create a macro that runs multiple plugins at once for efficiency. This can be done in a few ways, one of which is:
from FigureForge.plugins.set_spine_bounds import SetSpineBounds
from FigureForge.plugins.reduce_tick_limits import ReduceTickLimits
class Example:
name = "Format Spines"
def run(self, obj):
SetSpineBounds().run(obj)
ReduceTickLimits().run(obj)FigureForge loads all classes from all modules in the FigureForge\Plugins directory. That means if you want to create a class but don't intend it to be a plugin then you must place that class in a module outside the plugins directory. For instance, we could place our SelectSpinesDialog class in a submodule, such as:
plugins
├───utils
│ └───select_spines_dialog.py
└───example.py
Here, example.py contains our ExamplePlugin class, and utils\select_spines_dialog.py contains the SelectSpinesDialog class. Now, in example.py, we can add an import statement from .utils.select_spines_dialog import SelectSpinesDialog to access the class.
example.py
from tkinter.tix import Select
from matplotlib.figure import Figure
from matplotlib.axes import Axes
from matplotlib.spines import Spine
from PySide6.QtWidgets import QMessageBox, QDialog
from .utils.select_spines_dialog import SelectSpinesDialog
class ExamplePlugin:
name = "Remove Spines"
tooltip = "Remove spines from the selected item."
icon = "FigureForge/plugins/remove_spines.png"
submenu = "Spines"
def run(self, obj):
msg_success = QMessageBox()
msg_success.setText("Spines removed successfully.")
msg_success.setIcon(QMessageBox.Information)
if isinstance(obj, Spine):
obj.set_visible(False)
msg_success.exec()
elif isinstance(obj, (Figure, Axes)):
dialog = SelectSpinesDialog()
if dialog.exec() == QDialog.Accepted:
selected_spines = dialog.get_selected_spines()
if isinstance(obj, Figure):
for ax in obj.axes:
for spine, remove in selected_spines.items():
if remove:
ax.spines[spine].set_visible(False)
elif isinstance(obj, Axes):
for spine, remove in selected_spines.items():
if remove:
obj.spines[spine].set_visible(False)
msg_success.exec()
else:
msg_failure = QMessageBox()
msg_failure.setText(f"Invalid object type: {type(obj)}.")
msg_failure.setIcon(QMessageBox.Warning)
msg_failure.exec()utils\select_spines_dialog.py
from PySide6.QtWidgets import QMessageBox, QVBoxLayout, QCheckBox, QDialogButtonBox, QDialog
class SelectSpinesDialog(QDialog):
def __init__(self):
super().__init__()
self.setWindowTitle("Select Spines to Remove")
self.layout = QVBoxLayout(self)
self.spines = [
"top",
"bottom",
"left",
"right",
]
self.checkboxes = []
for spine in self.spines:
checkbox = QCheckBox(spine)
self.checkboxes.append(checkbox)
self.layout.addWidget(checkbox)
self.checkboxes[0].setChecked(True)
self.checkboxes[3].setChecked(True)
self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.buttons.accepted.connect(self.accept)
self.buttons.rejected.connect(self.reject)
self.layout.addWidget(self.buttons)
def get_selected_spines(self):
return {
spine: checkbox.isChecked()
for spine, checkbox in zip(self.spines, self.checkboxes)
}