Source code for plotpy.plot.manager
# -*- coding: utf-8 -*-
from __future__ import annotations
import weakref
from typing import TYPE_CHECKING, Any
from guidata.qthelpers import create_action
from guidata.utils.misc import assert_interfaces_valid
from qtpy import QtCore as QC
from qtpy import QtWidgets as QW
from plotpy.constants import ID_CONTRAST, ID_ITEMLIST, ID_XCS, ID_YCS
from plotpy.interfaces import IPlotManager
from plotpy.plot import BasePlot
if TYPE_CHECKING:
from typing import Callable
from qtpy.QtCore import Qt
from qtpy.QtGui import QIcon, QKeySequence
from qwt import QwtPlotCanvas, QwtScaleDiv
from plotpy.panels import (
ContrastAdjustment,
PanelWidget,
PlotItemList,
XCrossSection,
YCrossSection,
)
from plotpy.tools.base import GuiTool, GuiToolT
class DefaultPlotID:
pass
[docs]
class PlotManager:
"""
Construct a PlotManager object, a 'controller' that organizes relations
between plots (:py:class:`.BasePlot`), panels, tools and toolbars
Args:
main (QWidget): The main parent widget
"""
__implements__ = (IPlotManager,)
def __init__(self, main: QW.QWidget) -> None:
self.main = main # The main parent widget
self.plots = {} # maps ids to instances of BasePlot
self.panels = {} # Qt widgets that need to know about the plots
self.tools = []
self.toolbars = {}
self.active_tool = None
self.default_tool = None
self.default_plot = None
self.default_toolbar = None
self.synchronized_plots = {}
self.groups = {} # Action groups for grouping QActions
# Keep track of the registration sequence (plots, panels, tools):
self._first_tool_flag = True
[docs]
def add_plot(self, plot: BasePlot, plot_id: Any = DefaultPlotID) -> None:
"""
Register a plot to the plot manager:
* plot: :py:class:`.BasePlot`
* plot_id (default id is the plot object's id: ``id(plot)``):
unique ID identifying the plot (any Python object),
this ID will be asked by the manager to access this plot later.
Plot manager's registration sequence is the following:
1. add plots
2. add panels
3. add tools
"""
if plot_id is DefaultPlotID:
plot_id = id(plot)
assert plot_id not in self.plots
assert isinstance(plot, BasePlot)
assert not self.tools, "tools must be added after plots"
assert not self.panels, "panels must be added after plots"
self.plots[plot_id] = plot
if len(self.plots) == 1:
self.default_plot = plot
plot.set_manager(self, plot_id)
# Connecting signals
plot.SIG_ITEMS_CHANGED.connect(self.update_tools_status)
plot.SIG_ACTIVE_ITEM_CHANGED.connect(self.update_tools_status)
plot.SIG_PLOT_AXIS_CHANGED.connect(self.plot_axis_changed)
[docs]
def set_default_plot(self, plot: BasePlot) -> None:
"""
Set default plot
The default plot is the plot on which tools and panels will act.
"""
self.default_plot = plot
[docs]
def get_default_plot(self) -> BasePlot:
"""
Return default plot
The default plot is the plot on which tools and panels will act.
"""
return self.default_plot
[docs]
def add_panel(self, panel: PanelWidget) -> None:
"""
Register a panel to the plot manager
Plot manager's registration sequence is the following:
1. add plots
2. add panels
3. add tools
"""
assert panel.PANEL_ID not in self.panels
assert not self.tools, "tools must be added after panels"
self.panels[panel.PANEL_ID] = panel
panel.register_panel(self)
[docs]
def configure_panels(self) -> None:
"""
Call all the registred panels 'configure_panel' methods to finalize the
object construction (this allows to use tools registered to the same
plot manager as the panel itself with breaking the registration
sequence: "add plots, then panels, then tools")
"""
for panel_id in self.panels:
panel = self.get_panel(panel_id)
panel.configure_panel()
[docs]
def add_toolbar(self, toolbar: QW.QToolBar, toolbar_id: str = "default") -> None:
"""
Add toolbar to the plot manager
toolbar: a QToolBar object
toolbar_id: toolbar's id (default id is string "default")
"""
assert toolbar_id not in self.toolbars
self.toolbars[toolbar_id] = toolbar
if self.default_toolbar is None:
self.default_toolbar = toolbar
[docs]
def set_default_toolbar(self, toolbar: QW.QToolBar) -> None:
"""
Set default toolbar
"""
self.default_toolbar = toolbar
[docs]
def get_default_toolbar(self) -> QW.QToolBar:
"""
Return default toolbar
"""
return self.default_toolbar
[docs]
def add_tool(self, ToolKlass: type[GuiToolT], *args, **kwargs) -> GuiToolT:
"""
Register a tool to the manager
* ToolKlass: tool's class (see :ref:`tools`)
* args: arguments sent to the tool's class
* kwargs: keyword arguments sent to the tool's class
Plot manager's registration sequence is the following:
1. add plots
2. add panels
3. add tools
"""
if self._first_tool_flag:
# This is the very first tool to be added to this manager
self._first_tool_flag = False
self.configure_panels()
tool = ToolKlass(self, *args, **kwargs)
self.tools.append(tool)
for plot in list(self.plots.values()):
tool.register_plot(plot)
if len(self.tools) == 1 or self.default_tool is None:
self.default_tool = tool
return tool
[docs]
def get_tool(self, ToolKlass: type[GuiToolT]) -> GuiToolT | None:
"""Return tool instance from its class
Args:
ToolKlass: tool's class (see :ref:`tools`)
Returns:
GuiTool: tool instance
"""
for tool in self.tools:
if isinstance(tool, ToolKlass):
return tool
[docs]
def add_separator_tool(self, toolbar_id: str | None = None) -> None:
"""
Register a separator tool to the plot manager: the separator tool is
just a tool which insert a separator in the plot context menu
Args:
toolbar_id: toolbar's id (default to None)
"""
# This avoids circular imports (see Issue #39)
# pylint: disable=import-outside-toplevel
import plotpy.tools as tools
if toolbar_id is None:
for _id, toolbar in list(self.toolbars.items()):
if toolbar is self.get_default_toolbar():
toolbar_id = _id
break
self.add_tool(tools.DummySeparatorTool, toolbar_id)
[docs]
def set_default_tool(self, tool: GuiTool) -> None:
"""
Set default tool
Args:
tool: tool instance
"""
self.default_tool = tool
[docs]
def get_default_tool(self) -> GuiTool:
"""
Get default tool
Returns:
GuiTool: tool instance
"""
return self.default_tool
[docs]
def activate_default_tool(self) -> None:
"""
Activate default tool
"""
self.get_default_tool().activate()
[docs]
def get_active_tool(self) -> GuiTool:
"""
Return active tool
Returns:
GuiTool: tool instance
"""
return self.active_tool
[docs]
def set_active_tool(self, tool: GuiTool | None = None) -> None:
"""
Set active tool (if tool argument is None, the active tool will be
the default tool)
Args:
tool: tool instance or None
"""
self.active_tool = tool
[docs]
def get_plot(self, plot_id: Any = DefaultPlotID) -> BasePlot:
"""
Return plot associated to `plot_id` (if method is called without
specifying the `plot_id` parameter, return the default plot)
Args:
plot_id: plot's id (optional, default to DefaultPlotID)
"""
if plot_id is DefaultPlotID:
return self.default_plot
return self.plots[plot_id]
[docs]
def get_plots(self) -> list[BasePlot]:
"""
Return all registered plots
Returns:
list[BasePlot]: list of plots
"""
return list(self.plots.values())
[docs]
def get_active_plot(self) -> BasePlot:
"""
Return the active plot
The active plot is the plot whose canvas has the focus
otherwise it's the "default" plot
Returns:
BasePlot: plot instance
"""
for plot in list(self.plots.values()):
canvas: QwtPlotCanvas = plot.canvas()
if canvas.hasFocus():
return plot
return self.default_plot
[docs]
def get_tool_group(self, groupname: str) -> QW.QActionGroup:
"""
Return the QActionGroup associated to `groupname`
Args:
groupname: group's name
Returns:
QActionGroup: action group
"""
group = self.groups.get(groupname, None)
if group is None:
group = QW.QActionGroup(self.main)
self.groups[groupname] = weakref.ref(group)
return group
else:
return group()
[docs]
def get_main(self) -> QW.QWidget:
"""
Return the main (parent) widget
Note that for py:class:`.plot.PlotWidget` objects, this method will
return the widget itself because the plot manager is integrated to it.
Returns:
QWidget: main widget
"""
return self.main
[docs]
def set_main(self, main: QW.QWidget) -> None:
"""
Set the main (parent) widget
Args:
main: main widget
"""
self.main = main
[docs]
def get_panel(self, panel_id: str) -> PanelWidget:
"""
Return panel from its ID
Panel IDs are listed in module plotpy.panels
Args:
panel_id: panel's id
Returns:
PanelWidget: panel widget
"""
return self.panels.get(panel_id, None)
[docs]
def get_itemlist_panel(self) -> PlotItemList:
"""
Convenience function to get the `item list panel`
Return None if the item list panel has not been added to this manager
Returns:
PlotItemList: item list panel
"""
return self.get_panel(ID_ITEMLIST)
[docs]
def get_contrast_panel(self) -> ContrastAdjustment:
"""
Convenience function to get the `contrast adjustment panel`
Return None if the contrast adjustment panel has not been added
to this manager
"""
return self.get_panel(ID_CONTRAST)
[docs]
def set_contrast_range(self, zmin: float, zmax: float) -> None:
"""
Convenience function to set the `contrast adjustment panel` range
This is strictly equivalent to the following::
# Here, *widget* is for example a PlotWidget instance
# (the same apply for PlotWidget or any
# class deriving from PlotManager)
widget.get_contrast_panel().set_range(zmin, zmax)
Args:
zmin: minimum value
zmax: maximum value
"""
self.get_contrast_panel().set_range(zmin, zmax)
[docs]
def get_xcs_panel(self) -> XCrossSection:
"""
Convenience function to get the `X-axis cross section panel`
Return None if the X-axis cross section panel has not been added
to this manager
Returns:
XCrossSection: X-axis cross section panel
"""
return self.get_panel(ID_XCS)
[docs]
def get_ycs_panel(self) -> YCrossSection:
"""
Convenience function to get the `Y-axis cross section panel`
Return None if the Y-axis cross section panel has not been added
to this manager
Returns:
YCrossSection: Y-axis cross section panel
"""
return self.get_panel(ID_YCS)
[docs]
def update_cross_sections(self) -> None:
"""
Convenience function to update the `cross section panels` at once
This is strictly equivalent to the following::
# Here, *widget* is for example a PlotWidget instance
# (the same apply for any other class deriving from PlotManager)
widget.get_xcs_panel().update_plot()
widget.get_ycs_panel().update_plot()
"""
self.get_xcs_panel().update_plot()
self.get_ycs_panel().update_plot()
[docs]
def get_toolbar(self, toolbar_id: str = "default") -> QW.QToolBar:
"""
Return toolbar from its ID
Args:
toolbar_id: toolbar's id (default id is string "default")
Returns:
QToolBar: toolbar
"""
return self.toolbars.get(toolbar_id, None)
[docs]
def update_tools_status(self, plot: BasePlot | None = None) -> None:
"""
Update tools for current plot
Args:
plot: plot instance (default to None)
"""
if plot is None:
plot = self.get_plot()
for tool in self.tools:
tool.update_status(plot)
[docs]
def create_action(
self,
title: str,
triggered: Callable | None = None,
toggled: Callable | None = None,
shortcut: QKeySequence | None = None,
icon: QIcon | None = None,
tip: str | None = None,
checkable: bool | None = None,
context: Qt.ShortcutContext = QC.Qt.ShortcutContext.WindowShortcut,
enabled: bool | None = None,
):
"""
Create a new QAction
Args:
parent (QWidget or None): Parent widget
title (str): Action title
triggered (Callable or None): Triggered callback
toggled (Callable or None): Toggled callback
shortcut (QKeySequence or None): Shortcut
icon (QIcon or None): Icon
tip (str or None): Tooltip
checkable (bool or None): Checkable
context (Qt.ShortcutContext): Shortcut context
enabled (bool or None): Enabled
Returns:
QAction: New action
"""
return create_action(
self.main,
title,
triggered=triggered,
toggled=toggled,
shortcut=shortcut,
icon=icon,
tip=tip,
checkable=checkable,
context=context,
enabled=enabled,
)
# The following methods provide some sets of tools that
# are often registered together
[docs]
def register_standard_tools(self) -> None:
"""
Registering basic tools for standard plot dialog
--> top of the context-menu
"""
# This avoids circular imports (see Issue #39)
# pylint: disable=import-outside-toplevel
import plotpy.tools as tools
t = self.add_tool(tools.SelectTool)
self.set_default_tool(t)
self.add_tool(tools.RectangularSelectionTool, intersect=False)
self.add_tool(tools.RectZoomTool)
self.add_tool(tools.DoAutoscaleTool)
self.add_tool(tools.BasePlotMenuTool, "item")
self.add_tool(tools.ExportItemDataTool)
self.add_tool(tools.EditItemDataTool)
self.add_tool(tools.ItemCenterTool)
self.add_tool(tools.DeleteItemTool)
self.add_separator_tool()
self.add_tool(tools.BasePlotMenuTool, "grid")
self.add_tool(tools.BasePlotMenuTool, "axes")
self.add_tool(tools.DisplayCoordsTool)
if self.get_itemlist_panel():
self.add_tool(tools.ItemListPanelTool)
[docs]
def register_curve_tools(self) -> None:
"""
Register only curve-related tools
.. seealso::
:py:meth:`.plot.manager.PlotManager.add_tool`
:py:meth:`.plot.manager.PlotManager.register_standard_tools`
:py:meth:`.plot.manager.PlotManager.register_other_tools`
:py:meth:`.plot.manager.PlotManager.register_image_tools`
:py:meth:`.plot.manager.PlotManager.register_all_tools`
"""
# This avoids circular imports (see Issue #39)
# pylint: disable=import-outside-toplevel
import plotpy.tools as tools
self.add_tool(tools.CurveStatsTool)
self.add_tool(tools.YRangeCursorTool)
self.add_tool(tools.AntiAliasingTool)
self.add_tool(tools.AxisScaleTool)
self.add_tool(tools.DownSamplingTool)
[docs]
def register_image_tools(self) -> None:
"""
Register only image-related tools
.. seealso::
:py:meth:`.plot.manager.PlotManager.add_tool`
:py:meth:`.plot.manager.PlotManager.register_standard_tools`
:py:meth:`.plot.manager.PlotManager.register_other_tools`
:py:meth:`.plot.manager.PlotManager.register_curve_tools`
:py:meth:`.plot.manager.PlotManager.register_all_tools`
"""
# This avoids circular imports (see Issue #39)
# pylint: disable=import-outside-toplevel
import plotpy.tools as tools
self.add_tool(tools.ColormapTool)
self.add_tool(tools.ReverseColormapTool)
self.add_tool(tools.ReverseXAxisTool)
self.add_tool(tools.ReverseYAxisTool)
self.add_tool(tools.ZAxisLogTool)
self.add_tool(tools.AspectRatioTool)
if self.get_contrast_panel():
self.add_tool(tools.ContrastPanelTool)
self.add_tool(tools.SnapshotTool)
self.add_tool(tools.ImageStatsTool)
if self.get_xcs_panel() and self.get_ycs_panel():
self.add_tool(tools.XCSPanelTool)
self.add_tool(tools.YCSPanelTool)
self.add_tool(tools.CrossSectionTool)
self.add_tool(tools.AverageCrossSectionTool)
[docs]
def register_other_tools(self) -> None:
"""
Register other common tools
.. seealso::
:py:meth:`.plot.manager.PlotManager.add_tool`
:py:meth:`.plot.manager.PlotManager.register_standard_tools`
:py:meth:`.plot.manager.PlotManager.register_curve_tools`
:py:meth:`.plot.manager.PlotManager.register_image_tools`
:py:meth:`.plot.manager.PlotManager.register_all_tools`
"""
# This avoids circular imports (see Issue #39)
# pylint: disable=import-outside-toplevel
import plotpy.tools as tools
self.add_tool(tools.SaveAsTool)
self.add_tool(tools.CopyToClipboardTool)
self.add_tool(tools.PrintTool)
self.add_tool(tools.HelpTool)
self.add_tool(tools.AboutTool)
[docs]
def register_all_curve_tools(self) -> None:
"""
Register standard, curve-related and other tools
.. seealso::
:py:meth:`.plot.manager.PlotManager.add_tool`
:py:meth:`.plot.manager.PlotManager.register_standard_tools`
:py:meth:`.plot.manager.PlotManager.register_other_tools`
:py:meth:`.plot.manager.PlotManager.register_curve_tools`
:py:meth:`.plot.manager.PlotManager.register_image_tools`
:py:meth:`.plot.manager.PlotManager.register_all_image_tools`
:py:meth:`.plot.manager.PlotManager.register_all_tools`
"""
self.register_standard_tools()
self.add_separator_tool()
self.register_curve_tools()
self.add_separator_tool()
self.register_other_tools()
self.add_separator_tool()
self.update_tools_status()
self.get_default_tool().activate()
[docs]
def register_all_image_tools(self) -> None:
"""
Register standard, image-related and other tools
.. seealso::
:py:meth:`.plot.manager.PlotManager.add_tool`
:py:meth:`.plot.manager.PlotManager.register_standard_tools`
:py:meth:`.plot.manager.PlotManager.register_other_tools`
:py:meth:`.plot.manager.PlotManager.register_curve_tools`
:py:meth:`.plot.manager.PlotManager.register_image_tools`
:py:meth:`.plot.manager.PlotManager.register_all_curve_tools`
:py:meth:`.plot.manager.PlotManager.register_all_tools`
"""
self.register_standard_tools()
self.add_separator_tool()
self.register_image_tools()
self.add_separator_tool()
self.register_other_tools()
self.add_separator_tool()
self.update_tools_status()
self.get_default_tool().activate()
[docs]
def register_all_tools(self) -> None:
"""
Register standard, curve and image-related and other tools
.. seealso::
:py:meth:`.plot.manager.PlotManager.add_tool`
:py:meth:`.plot.manager.PlotManager.register_standard_tools`
:py:meth:`.plot.manager.PlotManager.register_other_tools`
:py:meth:`.plot.manager.PlotManager.register_curve_tools`
:py:meth:`.plot.manager.PlotManager.register_image_tools`
:py:meth:`.plot.manager.PlotManager.register_all_image_tools`
:py:meth:`.plot.manager.PlotManager.register_all_curve_tools`
"""
self.register_standard_tools()
self.add_separator_tool()
self.register_curve_tools()
self.add_separator_tool()
self.register_image_tools()
self.add_separator_tool()
self.register_other_tools()
self.add_separator_tool()
self.update_tools_status()
self.get_default_tool().activate()
[docs]
def register_all_annotation_tools(self) -> None:
"""
Register all annotation tools for the plot
"""
# This avoids circular imports (see Issue #39)
# pylint: disable=import-outside-toplevel
import plotpy.tools as tools
self.add_separator_tool()
self.add_tool(tools.AnnotatedPointTool)
self.add_tool(tools.AnnotatedSegmentTool)
self.add_tool(tools.AnnotatedRectangleTool)
self.add_tool(tools.AnnotatedPolygonTool)
self.add_tool(tools.AnnotatedObliqueRectangleTool)
self.add_tool(tools.AnnotatedCircleTool)
self.add_tool(tools.AnnotatedEllipseTool)
self.add_tool(tools.LabelTool)
[docs]
def register_curve_annotation_tools(self) -> None:
"""
Register all curve friendly annotation tools for the plot
"""
# This avoids circular imports (see Issue #39)
# pylint: disable=import-outside-toplevel
import plotpy.tools as tools
self.add_separator_tool()
self.add_tool(tools.AnnotatedPointTool)
self.add_tool(tools.AnnotatedSegmentTool)
self.add_tool(tools.LabelTool)
[docs]
def register_image_annotation_tools(self) -> None:
"""
Register all image friendly annotation tools for the plot
"""
# No curve-specific annotation tool, so this is equivalent to the
# register_all_annotation_tools function for now
self.register_all_annotation_tools()
[docs]
def synchronize_axis(self, axis_id: int, plot_ids: list[str]) -> None:
"""
Synchronize axis of plots
Args:
axis_id: axis id
plot_ids: list of plot ids
"""
for plot_id in plot_ids:
synclist = self.synchronized_plots.setdefault(plot_id, [])
for plot2_id in plot_ids:
if plot_id == plot2_id:
continue
item = (axis_id, plot2_id)
if item not in synclist:
synclist.append(item)
[docs]
def plot_axis_changed(self, plot: BasePlot) -> None:
"""
Plot axis changed, update other synchronized plots (if any)
Args:
plot: plot instance
"""
plot_id = plot.plot_id
if plot_id not in self.synchronized_plots:
return
for axis_id, other_plot_id in self.synchronized_plots[plot_id]:
scalediv: QwtScaleDiv = plot.axisScaleDiv(axis_id)
other_plot = self.get_plot(other_plot_id)
lb = scalediv.lowerBound()
ub = scalediv.upperBound()
other_plot.setAxisScale(axis_id, lb, ub)
other_plot.replot()
assert_interfaces_valid(PlotManager)