# -*- 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 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 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) 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 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 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) 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() 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 def set_default_toolbar(self, toolbar: QW.QToolBar) -> None: """ Set default toolbar """ self.default_toolbar = toolbar def get_default_toolbar(self) -> QW.QToolBar: """ Return default toolbar """ return self.default_toolbar 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 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 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) def set_default_tool(self, tool: GuiTool) -> None: """ Set default tool Args: tool: tool instance """ self.default_tool = tool def get_default_tool(self) -> GuiTool: """ Get default tool Returns: GuiTool: tool instance """ return self.default_tool def activate_default_tool(self) -> None: """ Activate default tool """ self.get_default_tool().activate() def get_active_tool(self) -> GuiTool: """ Return active tool Returns: GuiTool: tool instance """ return self.active_tool 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 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] def get_plots(self) -> list[BasePlot]: """ Return all registered plots Returns: list[BasePlot]: list of plots """ return list(self.plots.values()) 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 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() 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 def set_main(self, main: QW.QWidget) -> None: """ Set the main (parent) widget Args: main: main widget """ self.main = main 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) 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) 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) 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) 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) 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) 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() 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) def get_context_menu(self, plot: BasePlot | None = None) -> QW.QMenu: """ Return widget context menu -- built using active tools Args: plot: plot instance (default to None) Returns: QMenu: context menu """ if plot is None: plot = self.get_plot() menu = QW.QMenu(plot) self.update_tools_status(plot) for tool in self.tools: tool.setup_context_menu(menu, plot) return menu 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) 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 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) 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) 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) 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) 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() 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() 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() 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) 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) 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() 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) 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)