diff --git a/plottr/plot/base.py b/plottr/plot/base.py index 8b7217d8..a1e94399 100644 --- a/plottr/plot/base.py +++ b/plottr/plot/base.py @@ -9,11 +9,11 @@ from enum import Enum, unique, auto from types import TracebackType from typing import Dict, List, Type, Tuple, Optional, Any, \ - OrderedDict as OrderedDictType, Union + OrderedDict as OrderedDictType, Union, cast import numpy as np -from .. import Signal, Flowchart, QtWidgets +from .. import Signal, Flowchart, QtCore, QtWidgets from ..data.datadict import DataDictBase, DataDict, MeshgridDataDict from ..node import Node, linearFlowchart from ..utils import LabeledOptions @@ -22,6 +22,39 @@ __license__ = 'MIT' +class ClipboardMessageMixin: + """ + Mixin for widgets that show a transient clipboard-feedback message + and revert to a hint message after a short timeout. + + Subclasses must inherit from a ``QObject``, implement ``_displayMessage`` + to render the text, and call ``_initClipboardMessage`` once in ``__init__``. + """ + + CLIPBOARD_MESSAGE_DURATION_MS = 1500 + CLIPBOARD_HINT_MESSAGE = "Click on plot to copy coordinates" + + def _initClipboardMessage(self) -> None: + self.clipboardMessageActive = False + # cast: subclasses are guaranteed to be QObjects (see docstring), but + # the mixin can't inherit from QObject without breaking Qt-binding portability. + self.clipboardResetTimer = QtCore.QTimer(cast(QtCore.QObject, self)) + self.clipboardResetTimer.setSingleShot(True) + self.clipboardResetTimer.timeout.connect(self.clearClipboardMessage) + + def _displayMessage(self, message: str) -> None: + raise NotImplementedError + + def showClipboardMessage(self, message: str) -> None: + self.clipboardMessageActive = True + self._displayMessage(message) + self.clipboardResetTimer.start(self.CLIPBOARD_MESSAGE_DURATION_MS) + + def clearClipboardMessage(self) -> None: + self.clipboardMessageActive = False + self._displayMessage(self.CLIPBOARD_HINT_MESSAGE) + + class PlotNode(Node): """ Basic Plot Node, derived from :class:`plottr.node.node.Node`. diff --git a/plottr/plot/mpl/widgets.py b/plottr/plot/mpl/widgets.py index 67194f38..39b144c0 100644 --- a/plottr/plot/mpl/widgets.py +++ b/plottr/plot/mpl/widgets.py @@ -8,16 +8,37 @@ from matplotlib import rcParams from matplotlib.axes import Axes from matplotlib.figure import Figure +from matplotlib.backend_bases import LocationEvent, MouseButton, Event from matplotlib.backends.backend_qt5agg import ( FigureCanvasQTAgg as FCanvas, NavigationToolbar2QT as NavBar, ) from matplotlib.text import Text -from plottr import QtWidgets, QtGui, QtCore, config as plottrconfig +from plottr import QtWidgets, QtGui, QtCore, Signal, config as plottrconfig from plottr.data.datadict import DataDictBase from plottr.gui.tools import widgetDialog, dpiScalingFactor -from ..base import PlotWidget, PlotWidgetContainer +from ..base import PlotWidget, PlotWidgetContainer, ClipboardMessageMixin + + +class MPLNavBar(ClipboardMessageMixin, NavBar): + """ + Toolbar subclass that shows a hint when the mouse is outside the axes + and can pin a clipboard confirmation message, so hovering won't overwrite it + for a few seconds to make the message visible. + """ + + def __init__(self, canvas: FCanvas, parent: QtWidgets.QWidget): + super().__init__(canvas, parent) + self._initClipboardMessage() + + def set_message(self, s: str) -> None: + if self.clipboardMessageActive: + return + NavBar.set_message(self, s if s else self.CLIPBOARD_HINT_MESSAGE) + + def _displayMessage(self, message: str) -> None: + NavBar.set_message(self, message) class MPLPlot(FCanvas): @@ -29,6 +50,9 @@ class MPLPlot(FCanvas): It can be used as any QT widget. """ + #: Signal(str) -- emitted when content is copied to the clipboard, with a message describing what was copied. + clipboardCopied = Signal(str) + def __init__(self, parent: Optional[QtWidgets.QWidget] = None, width: float = 4.0, height: float = 3.0, dpi: int = 150, constrainedLayout: bool = True): @@ -116,12 +140,24 @@ def toClipboard(self) -> None: clipboard = QtWidgets.QApplication.clipboard() clipboard.setImage(QtGui.QImage.fromData(buf.getvalue())) buf.close() + self.clipboardCopied.emit("Figure copied to clipboard") def metaToClipboard(self) -> None: clipboard = QtWidgets.QApplication.clipboard() meta_info_string = "\n".join(f"{k}: {v}" for k, v in self._meta_info.items()) clipboard.setText(meta_info_string) + self.clipboardCopied.emit("Meta copied to clipboard") + + def coordinateToClipboard(self, event: Event) -> None: + if isinstance(event, LocationEvent): + if getattr(event, 'button', None) != MouseButton.LEFT: + return + clipboard = QtWidgets.QApplication.clipboard() + if event.xdata is not None and event.ydata is not None: + coord_info_string = '({:.8g}, {:.8g})'.format(event.xdata, event.ydata) + clipboard.setText(coord_info_string) + self.clipboardCopied.emit(f"Copied {coord_info_string} to clipboard") def setFigureTitle(self, title: str) -> None: """Add a title to the figure.""" @@ -153,7 +189,7 @@ def __init__(self, parent: Optional[PlotWidgetContainer] = None): self.plot = MPLPlot() #: the matplotlib toolbar - self.mplBar = NavBar(self.plot, self) + self.mplBar = MPLNavBar(self.plot, self) self.addMplBarOptions() defaultIconSize = int(16 * dpiScalingFactor(self)) @@ -161,7 +197,9 @@ def __init__(self, parent: Optional[PlotWidgetContainer] = None): layout = QtWidgets.QVBoxLayout(self) layout.addWidget(self.plot) layout.addWidget(self.mplBar) + self.setLayout(layout) + self.addPlotOptions() def setMeta(self, data: DataDictBase) -> None: """Add meta info contained in the data to the figure. @@ -196,6 +234,12 @@ def addMplBarOptions(self) -> None: self.mplBar.addAction('Copy Figure', self.plot.toClipboard) self.mplBar.addAction('Copy Meta', self.plot.metaToClipboard) + def addPlotOptions(self) -> None: + """Add options for copying coordinates to the clipboard""" + self.plot.mpl_connect('button_press_event', self.plot.coordinateToClipboard) + self.plot.clipboardCopied.connect(self.mplBar.showClipboardMessage) + self.mplBar.clearClipboardMessage() + def figureDialog() -> Tuple[Figure, QtWidgets.QDialog]: """Make a dialog window containing a :class:`.MPLPlotWidget`. @@ -204,4 +248,3 @@ def figureDialog() -> Tuple[Figure, QtWidgets.QDialog]: """ widget = MPLPlotWidget() return widget.plot.fig, widgetDialog(widget) - diff --git a/plottr/plot/pyqtgraph/autoplot.py b/plottr/plot/pyqtgraph/autoplot.py index 31b9e054..23011431 100644 --- a/plottr/plot/pyqtgraph/autoplot.py +++ b/plottr/plot/pyqtgraph/autoplot.py @@ -23,7 +23,7 @@ from .plots import Plot, PlotWithColorbar, PlotBase from ..base import AutoFigureMaker as BaseFM, PlotDataType, \ PlotItem, ComplexRepresentation, determinePlotDataType, \ - PlotWidgetContainer, PlotWidget + PlotWidgetContainer, PlotWidget, ClipboardMessageMixin logger = logging.getLogger(__name__) @@ -249,7 +249,7 @@ def _scatterPlot2d(self, plotItem: PlotItem) -> None: subPlot.setScatter2d(*plotItem.data) -class AutoPlot(PlotWidget): +class AutoPlot(ClipboardMessageMixin, PlotWidget): """Widget for automatic plotting with pyqtgraph. Uses :class:`.FigureMaker` to produce subplots. @@ -271,6 +271,11 @@ def __init__(self, parent: Optional[PlotWidgetContainer]) -> None: layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) + self._initClipboardMessage() + self.statusLabel = QtWidgets.QLabel(self.CLIPBOARD_HINT_MESSAGE) + self.statusLabel.setAlignment(QtCore.Qt.AlignCenter) + self.statusLabel.setStyleSheet("color: gray;") + self.setLayout(layout) self.setMinimumSize(*getcfg('main', 'pyqtgraph', 'minimum_plot_size', default=(400, 400))) @@ -327,6 +332,11 @@ def _plotData(self, **kwargs: Any) -> None: self.figConfig.optionsChanged.connect(self._refreshPlot) self.figConfig.figCopied.connect(self.onfigCopied) self.figConfig.figSaved.connect(self.onfigSaved) + self.layout().addWidget(self.statusLabel) + + if kwargs.get('clearWidget', True): + for subplot in self.fmWidget.subPlots: + self._connectPlotEvents(subplot) if self.data.has_meta('title'): self.fmWidget.setTitle(self.data.meta_val('title')) @@ -356,6 +366,41 @@ def _plotData(self, **kwargs: Any) -> None: def _refreshPlot(self) -> None: self._plotData() + def _displayMessage(self, message: str) -> None: + self.statusLabel.setText(message) + + def setStatusMessage(self, message: str) -> None: + if not self.clipboardMessageActive: + self.statusLabel.setText(message if message else self.CLIPBOARD_HINT_MESSAGE) + + def _connectPlotEvents(self, subplot: PlotBase) -> None: + subplot.plot.scene().sigMouseMoved.connect( + lambda pos, sp=subplot: self._onMouseMoved(sp, pos) + ) + subplot.plot.scene().sigMouseClicked.connect( + lambda event, sp=subplot: self._onPlotClicked(sp, event) + ) + + def _onMouseMoved(self, subplot: PlotBase, pos: Any) -> None: + vb = subplot.plot.getViewBox() + local = vb.mapFromScene(pos) + if vb.boundingRect().contains(local): + pt = vb.mapSceneToView(pos) + self.setStatusMessage(f'({pt.x():.8g}, {pt.y():.8g})') + else: + self.setStatusMessage('') + + def _onPlotClicked(self, subplot: PlotBase, event: Any) -> None: + if event.button() != QtCore.Qt.LeftButton: + return + vb = subplot.plot.getViewBox() + local = vb.mapFromScene(event.scenePos()) + if vb.boundingRect().contains(local): + pt = vb.mapSceneToView(event.scenePos()) + coord_str = '({:.8g}, {:.8g})'.format(pt.x(), pt.y()) + QtWidgets.QApplication.clipboard().setText(coord_str) + self.showClipboardMessage(f"Copied {coord_str} to clipboard") + @Slot() def onfigCopied(self) -> None: """ @@ -366,6 +411,7 @@ def onfigCopied(self) -> None: screenshot = self.fmWidget.grab(rectangle=QtCore.QRect(QtCore.QPoint(0, 0), QtCore.QSize(-1, -1))) clipboard = QtWidgets.QApplication.clipboard() clipboard.setImage(screenshot.toImage()) + self.showClipboardMessage("Figure copied to clipboard") @Slot() def onfigSaved(self) -> None: @@ -449,7 +495,6 @@ def __init__(self, options: FigureOptions, self.copyFig = self.addAction('Copy Figure', self._copyFig) self.saveFig = self.addAction('Save Figure', self._saveFig) - def _setOption(self, option: str, value: Any) -> None: setattr(self.options, option, value) self.optionsChanged.emit()