Move multi-view overlay components in from `pyqtgraph` PR
This brings in the WIP components developed as part of https://github.com/pyqtgraph/pyqtgraph/pull/2162. Most of the history can be understood from that issue and effort but the TL;DR is, - add an event handler wrapper system which can be used to wrap `ViewBox` methods such that multiple views can be overlayed and a single event stream broadcast from one "main" view to others which are overlaid with it. - add in 2 relay `Signal` attrs to our `ViewBox` subtype (`Chartview`) to accomplish per event `MouseEvent.emit()` style broadcasting to multiple (sub-)views. - Add a `PlotItemOverlay` api which does all the work of overlaying the actual chart graphics and arranging multiple-axes without collision as well as tying together all the event/signalling so that only a single "focussed" view relays to all overlays.plotitem_overlays
parent
637c9c65e9
commit
4b89f7197a
|
@ -18,17 +18,27 @@
|
||||||
Charting overlay helpers.
|
Charting overlay helpers.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from pyqtgraph.Qt.QtCore import (
|
from pyqtgraph.Qt.QtCore import (
|
||||||
# QObject,
|
# QObject,
|
||||||
# Signal,
|
# Signal,
|
||||||
Qt,
|
Qt,
|
||||||
# QEvent,
|
# QEvent,
|
||||||
)
|
)
|
||||||
|
|
||||||
from pyqtgraph.graphicsItems.AxisItem import AxisItem
|
from pyqtgraph.graphicsItems.AxisItem import AxisItem
|
||||||
# from pyqtgraph.graphicsItems.ViewBox import ViewBox
|
from pyqtgraph.graphicsItems.ViewBox import ViewBox
|
||||||
|
from pyqtgraph.graphicsItems.GraphicsWidget import GraphicsWidget
|
||||||
from pyqtgraph.graphicsItems.PlotItem.PlotItem import PlotItem
|
from pyqtgraph.graphicsItems.PlotItem.PlotItem import PlotItem
|
||||||
|
from pyqtgraph.Qt.QtCore import QObject, Signal, QEvent
|
||||||
from pyqtgraph.Qt.QtWidgets import QGraphicsGridLayout, QGraphicsLinearLayout
|
from pyqtgraph.Qt.QtWidgets import QGraphicsGridLayout, QGraphicsLinearLayout
|
||||||
|
|
||||||
|
from ._interaction import ChartView
|
||||||
|
|
||||||
|
__all__ = ["PlotItemOverlay"]
|
||||||
|
|
||||||
|
|
||||||
# Define the layout "position" indices as to be passed
|
# Define the layout "position" indices as to be passed
|
||||||
# to a ``QtWidgets.QGraphicsGridlayout.addItem()`` call:
|
# to a ``QtWidgets.QGraphicsGridlayout.addItem()`` call:
|
||||||
# https://doc.qt.io/qt-5/qgraphicsgridlayout.html#addItem
|
# https://doc.qt.io/qt-5/qgraphicsgridlayout.html#addItem
|
||||||
|
@ -166,7 +176,7 @@ class ComposedGridLayout:
|
||||||
|
|
||||||
'''
|
'''
|
||||||
if index < 0:
|
if index < 0:
|
||||||
raise ValueError(f'`insert()` only supports an index >= 0')
|
raise ValueError('`insert()` only supports an index >= 0')
|
||||||
|
|
||||||
# add plot's axes in sequence to the embedded linear layouts
|
# add plot's axes in sequence to the embedded linear layouts
|
||||||
# for each "side" thus avoiding graphics collisions.
|
# for each "side" thus avoiding graphics collisions.
|
||||||
|
@ -250,4 +260,372 @@ class ComposedGridLayout:
|
||||||
Remove item and restack all axes in list-order.
|
Remove item and restack all axes in list-order.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
raise NotImplemented
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
# Unimplemented features TODO:
|
||||||
|
# - 'A' (autobtn) should relay to all views
|
||||||
|
# - context menu single handler + relay?
|
||||||
|
# - layout unwind and re-pack for 'left' and 'top' axes
|
||||||
|
# - add labels to layout if detected in source ``PlotItem``
|
||||||
|
|
||||||
|
# UX nice-to-have TODO:
|
||||||
|
# - optional "focussed" view box support for view boxes
|
||||||
|
# that have custom input handlers (eg. you might want to
|
||||||
|
# scale the view to some "focussed" data view and have overlayed
|
||||||
|
# viewboxes only respond to relayed events.)
|
||||||
|
# - figure out how to deal with menu raise events for multi-viewboxes.
|
||||||
|
# (we might want to add a different menu which specs the name of the
|
||||||
|
# view box currently being handled?
|
||||||
|
# - allow selection of a particular view box by interacting with its
|
||||||
|
# axis?
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: we might want to enabled some kind of manual flag to disable
|
||||||
|
# this method wrapping during type creation? As example a user could
|
||||||
|
# definitively decide **not** to enable broadcasting support by
|
||||||
|
# setting something like ``ViewBox.disable_relays = True``?
|
||||||
|
def mk_relay_method(
|
||||||
|
|
||||||
|
signame: str,
|
||||||
|
slot: Callable[
|
||||||
|
[ViewBox,
|
||||||
|
'QEvent',
|
||||||
|
Optional[AxisItem]],
|
||||||
|
None,
|
||||||
|
],
|
||||||
|
|
||||||
|
) -> Callable[
|
||||||
|
[
|
||||||
|
ViewBox,
|
||||||
|
# lol, there isn't really a generic type thanks
|
||||||
|
# to the rewrite of Qt's event system XD
|
||||||
|
'QEvent',
|
||||||
|
|
||||||
|
'Optional[AxisItem]',
|
||||||
|
'Optional[ViewBox]', # the ``relayed_from`` arg we provide
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
]:
|
||||||
|
|
||||||
|
def maybe_broadcast(
|
||||||
|
vb: 'ViewBox',
|
||||||
|
ev: 'QEvent',
|
||||||
|
axis: 'Optional[int]' = None,
|
||||||
|
relayed_from: 'ViewBox' = None,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
(soon to be) Decorator which makes an event handler
|
||||||
|
"broadcastable" to overlayed ``GraphicsWidget``s.
|
||||||
|
|
||||||
|
Adds relay signals based on the decorated handler's name
|
||||||
|
and conducts a signal broadcast of the relay signal if there
|
||||||
|
are consumers registered.
|
||||||
|
|
||||||
|
'''
|
||||||
|
# When no relay source has been set just bypass all
|
||||||
|
# the broadcast machinery.
|
||||||
|
if vb.event_relay_source is None:
|
||||||
|
ev.accept()
|
||||||
|
return slot(
|
||||||
|
vb,
|
||||||
|
ev,
|
||||||
|
axis=axis,
|
||||||
|
)
|
||||||
|
|
||||||
|
if relayed_from:
|
||||||
|
assert axis is None
|
||||||
|
|
||||||
|
# this is a relayed event and should be ignored (so it does not
|
||||||
|
# halt/short circuit the graphicscene loop). Further the
|
||||||
|
# surrounding handler for this signal must be allowed to execute
|
||||||
|
# and get processed by **this consumer**.
|
||||||
|
print(f'{vb.name} rx relayed from {relayed_from.name}')
|
||||||
|
ev.ignore()
|
||||||
|
|
||||||
|
return slot(
|
||||||
|
vb,
|
||||||
|
ev,
|
||||||
|
axis=axis,
|
||||||
|
)
|
||||||
|
|
||||||
|
if axis is not None:
|
||||||
|
print(f'{vb.name} handling axis event:\n{str(ev)}')
|
||||||
|
ev.accept()
|
||||||
|
return slot(
|
||||||
|
vb,
|
||||||
|
ev,
|
||||||
|
axis=axis,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif (
|
||||||
|
relayed_from is None
|
||||||
|
and vb.event_relay_source is vb # we are the broadcaster
|
||||||
|
and axis is None
|
||||||
|
):
|
||||||
|
# Broadcast case: this is a source event which will be
|
||||||
|
# relayed to attached consumers and accepted after all
|
||||||
|
# consumers complete their own handling followed by this
|
||||||
|
# routine's processing. Sequence is,
|
||||||
|
# - pre-relay to all consumers *first* - ``.emit()`` blocks
|
||||||
|
# until all downstream relay handlers have run.
|
||||||
|
# - run the source handler for **this** event and accept
|
||||||
|
# the event
|
||||||
|
|
||||||
|
# Access the "bound signal" that is created
|
||||||
|
# on the widget type as part of instantiation.
|
||||||
|
signal = getattr(vb, signame)
|
||||||
|
# print(f'{vb.name} emitting {signame}')
|
||||||
|
|
||||||
|
# TODO/NOTE: we could also just bypass a "relay" signal
|
||||||
|
# entirely and instead call the handlers manually in
|
||||||
|
# a loop? This probably is a lot simpler and also doesn't
|
||||||
|
# have any downside, and allows not touching target widget
|
||||||
|
# internals.
|
||||||
|
signal.emit(
|
||||||
|
ev,
|
||||||
|
axis,
|
||||||
|
# passing this demarks a broadcasted/relayed event
|
||||||
|
vb,
|
||||||
|
)
|
||||||
|
# accept event so no more relays are fired.
|
||||||
|
ev.accept()
|
||||||
|
|
||||||
|
# call underlying wrapped method with an extra
|
||||||
|
# ``relayed_from`` value to denote that this is a relayed
|
||||||
|
# event handling case.
|
||||||
|
return slot(
|
||||||
|
vb,
|
||||||
|
ev,
|
||||||
|
axis=axis,
|
||||||
|
)
|
||||||
|
|
||||||
|
return maybe_broadcast
|
||||||
|
|
||||||
|
|
||||||
|
# XXX: :( can't define signals **after** class compile time
|
||||||
|
# so this is not really useful.
|
||||||
|
# def mk_relay_signal(
|
||||||
|
# func,
|
||||||
|
# name: str = None,
|
||||||
|
|
||||||
|
# ) -> Signal:
|
||||||
|
# (
|
||||||
|
# args,
|
||||||
|
# varargs,
|
||||||
|
# varkw,
|
||||||
|
# defaults,
|
||||||
|
# kwonlyargs,
|
||||||
|
# kwonlydefaults,
|
||||||
|
# annotations
|
||||||
|
# ) = inspect.getfullargspec(func)
|
||||||
|
|
||||||
|
# # XXX: generate a relay signal with 1 extra
|
||||||
|
# # argument for a ``relayed_from`` kwarg. Since
|
||||||
|
# # ``'self'`` is already ignored by signals we just need
|
||||||
|
# # to count the arguments since we're adding only 1 (and
|
||||||
|
# # ``args`` will capture that).
|
||||||
|
# numargs = len(args + list(defaults))
|
||||||
|
# signal = Signal(*tuple(numargs * [object]))
|
||||||
|
# signame = name or func.__name__ + 'Relay'
|
||||||
|
# return signame, signal
|
||||||
|
|
||||||
|
|
||||||
|
def enable_relays(
|
||||||
|
widget: GraphicsWidget,
|
||||||
|
handler_names: list[str],
|
||||||
|
|
||||||
|
) -> list[Signal]:
|
||||||
|
'''
|
||||||
|
Method override helper which enables relay of a particular
|
||||||
|
``Signal`` from some chosen broadcaster widget to a set of
|
||||||
|
consumer widgets which should operate their event handlers normally
|
||||||
|
but instead of signals "relayed" from the broadcaster.
|
||||||
|
|
||||||
|
Mostly useful for overlaying widgets that handle user input
|
||||||
|
that you want to overlay graphically. The target ``widget`` type must
|
||||||
|
define ``QtCore.Signal``s each with a `'Relay'` suffix for each
|
||||||
|
name provided in ``handler_names: list[str]``.
|
||||||
|
|
||||||
|
'''
|
||||||
|
signals = []
|
||||||
|
for name in handler_names:
|
||||||
|
handler = getattr(widget, name)
|
||||||
|
signame = name + 'Relay'
|
||||||
|
# ensure the target widget defines a relay signal
|
||||||
|
relay = getattr(widget, signame)
|
||||||
|
widget.relays[signame] = name
|
||||||
|
signals.append(relay)
|
||||||
|
method = mk_relay_method(signame, handler)
|
||||||
|
setattr(widget, name, method)
|
||||||
|
|
||||||
|
return signals
|
||||||
|
|
||||||
|
|
||||||
|
enable_relays(
|
||||||
|
ChartView,
|
||||||
|
['wheelEvent', 'mouseDragEvent']
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PlotItemOverlay:
|
||||||
|
'''
|
||||||
|
A composite for managing overlaid ``PlotItem`` instances such that
|
||||||
|
you can make multiple graphics appear on the same graph with
|
||||||
|
separate (non-colliding) axes apply ``ViewBox`` signal broadcasting
|
||||||
|
such that all overlaid items respond to input simultaneously.
|
||||||
|
|
||||||
|
'''
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
root_plotitem: PlotItem
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
self.root_plotitem: PlotItem = root_plotitem
|
||||||
|
|
||||||
|
vb = root_plotitem.vb
|
||||||
|
vb.event_relay_source = vb # TODO: maybe change name?
|
||||||
|
vb.setZValue(1000) # XXX: critical for scene layering/relaying
|
||||||
|
|
||||||
|
self.overlays: list[PlotItem] = []
|
||||||
|
from piker.ui._overlay import ComposedGridLayout
|
||||||
|
self.layout = ComposedGridLayout(
|
||||||
|
root_plotitem,
|
||||||
|
root_plotitem.layout,
|
||||||
|
)
|
||||||
|
self._relays: dict[str, Signal] = {}
|
||||||
|
|
||||||
|
def add_plotitem(
|
||||||
|
self,
|
||||||
|
plotitem: PlotItem,
|
||||||
|
index: Optional[int] = None,
|
||||||
|
|
||||||
|
# TODO: we could also put the ``ViewBox.XAxis``
|
||||||
|
# style enum here?
|
||||||
|
# (0,), # link x
|
||||||
|
# (1,), # link y
|
||||||
|
# (0, 1), # link both
|
||||||
|
link_axes: tuple[int] = (),
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
index = index or 0
|
||||||
|
root = self.root_plotitem
|
||||||
|
# layout: QGraphicsGridLayout = root.layout
|
||||||
|
self.overlays.insert(index, plotitem)
|
||||||
|
vb: ViewBox = plotitem.vb
|
||||||
|
|
||||||
|
# mark this consumer overlay as ready to expect relayed events
|
||||||
|
# from the root plotitem.
|
||||||
|
vb.event_relay_source = root.vb
|
||||||
|
|
||||||
|
# TODO: some sane way to allow menu event broadcast XD
|
||||||
|
# vb.setMenuEnabled(False)
|
||||||
|
|
||||||
|
# TODO: inside the `maybe_broadcast()` (soon to be) decorator
|
||||||
|
# we need have checks that consumers have been attached to
|
||||||
|
# these relay signals.
|
||||||
|
if link_axes != (0, 1):
|
||||||
|
|
||||||
|
# wire up relay signals
|
||||||
|
for relay_signal_name, handler_name in vb.relays.items():
|
||||||
|
# print(handler_name)
|
||||||
|
# XXX: Signal class attrs are bound after instantiation
|
||||||
|
# of the defining type, so we need to access that bound
|
||||||
|
# version here.
|
||||||
|
signal = getattr(root.vb, relay_signal_name)
|
||||||
|
handler = getattr(vb, handler_name)
|
||||||
|
signal.connect(handler)
|
||||||
|
|
||||||
|
# link dim-axes to root if requested by user.
|
||||||
|
# TODO: solve more-then-wanted scaled panning on click drag
|
||||||
|
# which seems to be due to broadcast. So we probably need to
|
||||||
|
# disable broadcast when axes are linked in a particular
|
||||||
|
# dimension?
|
||||||
|
for dim in link_axes:
|
||||||
|
# link x and y axes to new view box such that the top level
|
||||||
|
# viewbox propagates to the root (and whatever other
|
||||||
|
# plotitem overlays that have been added).
|
||||||
|
vb.linkView(dim, root.vb)
|
||||||
|
|
||||||
|
# make overlaid viewbox impossible to focus since the top
|
||||||
|
# level should handle all input and relay to overlays.
|
||||||
|
# NOTE: this was solved with the `setZValue()` above!
|
||||||
|
|
||||||
|
# TODO: we will probably want to add a "focus" api such that
|
||||||
|
# a new "top level" ``PlotItem`` can be selected dynamically
|
||||||
|
# (and presumably the axes dynamically sorted to match).
|
||||||
|
vb.setFlag(
|
||||||
|
vb.GraphicsItemFlag.ItemIsFocusable,
|
||||||
|
False
|
||||||
|
)
|
||||||
|
vb.setFocusPolicy(Qt.NoFocus)
|
||||||
|
|
||||||
|
# append-compose into the layout all axes from this plot
|
||||||
|
self.layout.insert(index, plotitem)
|
||||||
|
|
||||||
|
plotitem.setGeometry(root.vb.sceneBoundingRect())
|
||||||
|
|
||||||
|
def size_to_viewbox(vb: 'ViewBox'):
|
||||||
|
plotitem.setGeometry(vb.sceneBoundingRect())
|
||||||
|
|
||||||
|
root.vb.sigResized.connect(size_to_viewbox)
|
||||||
|
|
||||||
|
# ensure the overlayed view is redrawn on each cycle
|
||||||
|
root.scene().sigPrepareForPaint.connect(vb.prepareForPaint)
|
||||||
|
|
||||||
|
# focus state sanity
|
||||||
|
vb.clearFocus()
|
||||||
|
assert not vb.focusWidget()
|
||||||
|
root.vb.setFocus()
|
||||||
|
assert root.vb.focusWidget()
|
||||||
|
|
||||||
|
# XXX: do we need this? Why would you build then destroy?
|
||||||
|
def remove_plotitem(self, plotItem: PlotItem) -> None:
|
||||||
|
'''
|
||||||
|
Remove this ``PlotItem`` from the overlayed set making not shown
|
||||||
|
and unable to accept input.
|
||||||
|
|
||||||
|
'''
|
||||||
|
...
|
||||||
|
|
||||||
|
# TODO: i think this would be super hot B)
|
||||||
|
def focus_item(self, plotitem: PlotItem) -> PlotItem:
|
||||||
|
'''
|
||||||
|
Apply focus to a contained PlotItem thus making it the "top level"
|
||||||
|
item in the overlay able to accept peripheral's input from the user
|
||||||
|
and responsible for zoom and panning control via its ``ViewBox``.
|
||||||
|
|
||||||
|
'''
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_axis(
|
||||||
|
self,
|
||||||
|
plot: PlotItem,
|
||||||
|
name: str,
|
||||||
|
|
||||||
|
) -> AxisItem:
|
||||||
|
'''
|
||||||
|
Retrieve the named axis for overlayed ``plot``.
|
||||||
|
|
||||||
|
'''
|
||||||
|
return self.layout.get_axis(plot, name)
|
||||||
|
|
||||||
|
# TODO: i guess we need this if you want to detach existing plots
|
||||||
|
# dynamically? XXX: untested as of now.
|
||||||
|
def _disconnect_all(
|
||||||
|
self,
|
||||||
|
plotitem: PlotItem,
|
||||||
|
) -> list[Signal]:
|
||||||
|
'''
|
||||||
|
Disconnects all signals related to this widget for the given chart.
|
||||||
|
|
||||||
|
'''
|
||||||
|
disconnected = []
|
||||||
|
for pi, sig in self._relays.items():
|
||||||
|
QObject.disconnect(sig)
|
||||||
|
disconnected.append(sig)
|
||||||
|
|
||||||
|
return disconnected
|
||||||
|
|
Loading…
Reference in New Issue