Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions plottr/plot/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`.
Expand Down
51 changes: 47 additions & 4 deletions plottr/plot/mpl/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -153,15 +189,17 @@ 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))
self.mplBar.setIconSize(QtCore.QSize(defaultIconSize, defaultIconSize))
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.
Expand Down Expand Up @@ -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`.
Expand All @@ -204,4 +248,3 @@ def figureDialog() -> Tuple[Figure, QtWidgets.QDialog]:
"""
widget = MPLPlotWidget()
return widget.plot.fig, widgetDialog(widget)

51 changes: 48 additions & 3 deletions plottr/plot/pyqtgraph/autoplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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.
Expand All @@ -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)))
Expand Down Expand Up @@ -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'))
Expand Down Expand Up @@ -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:
"""
Expand All @@ -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:
Expand Down Expand Up @@ -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()
Expand Down