649 lines
20 KiB
Python
649 lines
20 KiB
Python
# piker: trading gear for hackers
|
|
# Copyright (C) Tyler Goodlet (in stewardship for pikers)
|
|
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU Affero General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
'''
|
|
Charting overlay helpers.
|
|
|
|
'''
|
|
from typing import Callable, Optional
|
|
|
|
from pyqtgraph.Qt.QtCore import (
|
|
# QObject,
|
|
# Signal,
|
|
Qt,
|
|
# QEvent,
|
|
)
|
|
|
|
from pyqtgraph.graphicsItems.AxisItem import AxisItem
|
|
from pyqtgraph.graphicsItems.ViewBox import ViewBox
|
|
from pyqtgraph.graphicsItems.GraphicsWidget import GraphicsWidget
|
|
from pyqtgraph.graphicsItems.PlotItem.PlotItem import PlotItem
|
|
from pyqtgraph.Qt.QtCore import QObject, Signal, QEvent
|
|
from pyqtgraph.Qt.QtWidgets import QGraphicsGridLayout, QGraphicsLinearLayout
|
|
|
|
from ._interaction import ChartView
|
|
|
|
__all__ = ["PlotItemOverlay"]
|
|
|
|
|
|
# Define the layout "position" indices as to be passed
|
|
# to a ``QtWidgets.QGraphicsGridlayout.addItem()`` call:
|
|
# https://doc.qt.io/qt-5/qgraphicsgridlayout.html#addItem
|
|
# This was pulled from the internals of ``PlotItem.setAxisItem()``.
|
|
_axes_layout_indices: dict[str] = {
|
|
# row incremented axes
|
|
'top': (1, 1),
|
|
'bottom': (3, 1),
|
|
|
|
# view is @ (2, 1)
|
|
|
|
# column incremented axes
|
|
'left': (2, 0),
|
|
'right': (2, 2),
|
|
}
|
|
# NOTE: To clarify this indexing, ``PlotItem.__init__()`` makes a grid
|
|
# with dimensions 4x3 and puts the ``ViewBox`` at postiion (2, 1) (aka
|
|
# row=2, col=1) in the grid layout since row (0, 1) is reserved for
|
|
# a title label and row 1 is for any potential "top" axis. Column 1
|
|
# is the "middle" (since 3 columns) and is where the plot/vb is placed.
|
|
|
|
|
|
class ComposedGridLayout:
|
|
'''
|
|
List-like interface to managing a sequence of overlayed
|
|
``PlotItem``s in the form:
|
|
|
|
| | | | | top0 | | | | |
|
|
| | | | | top1 | | | | |
|
|
| | | | | ... | | | | |
|
|
| | | | | topN | | | | |
|
|
| lN | ... | l1 | l0 | ViewBox | r0 | r1 | ... | rN |
|
|
| | | | | bottom0 | | | | |
|
|
| | | | | bottom1 | | | | |
|
|
| | | | | ... | | | | |
|
|
| | | | | bottomN | | | | |
|
|
|
|
Where the index ``i`` in the sequence specifies the index
|
|
``<axis_name>i`` in the layout.
|
|
|
|
The ``item: PlotItem`` passed to the constructor's grid layout is
|
|
used verbatim as the "main plot" who's view box is give precedence
|
|
for input handling. The main plot's axes are removed from it's
|
|
layout and placed in the surrounding exterior layouts to allow for
|
|
re-ordering if desired.
|
|
|
|
'''
|
|
def __init__(
|
|
self,
|
|
item: PlotItem,
|
|
grid: QGraphicsGridLayout,
|
|
reverse: bool = False, # insert items to the "center"
|
|
|
|
) -> None:
|
|
self.items: list[PlotItem] = []
|
|
# self.grid = grid
|
|
self.reverse = reverse
|
|
|
|
# TODO: use a ``bidict`` here?
|
|
self._pi2axes: dict[
|
|
int,
|
|
dict[str, AxisItem],
|
|
] = {}
|
|
|
|
# TODO: better name?
|
|
# construct surrounding layouts for placing outer axes and
|
|
# their legends and title labels.
|
|
self.sides: dict[
|
|
str,
|
|
tuple[QGraphicsLinearLayout, list[AxisItem]]
|
|
] = {}
|
|
|
|
for name, pos in _axes_layout_indices.items():
|
|
layout = QGraphicsLinearLayout()
|
|
self.sides[name] = (layout, [])
|
|
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.setSpacing(0)
|
|
|
|
if name in ('top', 'bottom'):
|
|
orient = Qt.Vertical
|
|
elif name in ('left', 'right'):
|
|
orient = Qt.Horizontal
|
|
|
|
layout.setOrientation(orient)
|
|
|
|
self.insert(0, item)
|
|
|
|
# insert surrounding linear layouts into the parent pi's layout
|
|
# such that additional axes can be appended arbitrarily without
|
|
# having to expand or resize the parent's grid layout.
|
|
for name, (linlayout, axes) in self.sides.items():
|
|
|
|
# TODO: do we need this?
|
|
# axis should have been removed during insert above
|
|
index = _axes_layout_indices[name]
|
|
axis = item.layout.itemAt(*index)
|
|
if axis and axis.isVisible():
|
|
assert linlayout.itemAt(0) is axis
|
|
|
|
# item.layout.removeItem(axis)
|
|
item.layout.addItem(linlayout, *index)
|
|
layout = item.layout.itemAt(*index)
|
|
assert layout is linlayout
|
|
|
|
def _register_item(
|
|
self,
|
|
index: int,
|
|
plotitem: PlotItem,
|
|
|
|
) -> None:
|
|
for name, axis_info in plotitem.axes.items():
|
|
axis = axis_info['item']
|
|
# register this plot's (maybe re-placed) axes for lookup.
|
|
# print(f'inserting {name}:{axis} to index {index}')
|
|
self._pi2axes.setdefault(name, {})[index] = axis
|
|
|
|
# enter plot into list for index tracking
|
|
self.items.insert(index, plotitem)
|
|
|
|
def insert(
|
|
self,
|
|
index: int,
|
|
plotitem: PlotItem,
|
|
|
|
) -> (int, int):
|
|
'''
|
|
Place item at index by inserting all axes into the grid
|
|
at list-order appropriate position.
|
|
|
|
'''
|
|
if index < 0:
|
|
raise ValueError('`insert()` only supports an index >= 0')
|
|
|
|
# add plot's axes in sequence to the embedded linear layouts
|
|
# for each "side" thus avoiding graphics collisions.
|
|
for name, axis_info in plotitem.axes.copy().items():
|
|
linlayout, axes = self.sides[name]
|
|
axis = axis_info['item']
|
|
|
|
if axis in axes:
|
|
# TODO: re-order using ``.pop()`` ?
|
|
ValueError(f'{axis} is already in {name} layout!?')
|
|
|
|
# linking sanity
|
|
axis_view = axis.linkedView()
|
|
assert axis_view is plotitem.vb
|
|
|
|
if (
|
|
not axis.isVisible()
|
|
|
|
# XXX: we never skip moving the axes for the *first*
|
|
# plotitem inserted (even if not shown) since we need to
|
|
# move all the hidden axes into linear sub-layouts for
|
|
# that "central" plot in the overlay. Also if we don't
|
|
# do it there's weird geomoetry calc offsets that make
|
|
# view coords slightly off somehow .. smh
|
|
and not len(self.items) == 0
|
|
):
|
|
continue
|
|
|
|
# XXX: Remove old axis? No, turns out we don't need this?
|
|
# DON'T unlink it since we the original ``ViewBox``
|
|
# to still drive it B)
|
|
# popped = plotitem.removeAxis(name, unlink=False)
|
|
# assert axis is popped
|
|
|
|
# invert insert index for layouts which are
|
|
# not-left-to-right, top-to-bottom insert oriented
|
|
insert_index = index
|
|
if name in ('top', 'left'):
|
|
insert_index = min(len(axes) - index, 0)
|
|
assert insert_index >= 0
|
|
|
|
linlayout.insertItem(insert_index, axis)
|
|
axes.insert(index, axis)
|
|
|
|
self._register_item(index, plotitem)
|
|
|
|
return index
|
|
|
|
def append(
|
|
self,
|
|
item: PlotItem,
|
|
|
|
) -> (int, int):
|
|
'''
|
|
Append item's axes at indexes which put its axes "outside"
|
|
previously overlayed entries.
|
|
|
|
'''
|
|
# for left and bottom axes we have to first remove
|
|
# items and re-insert to maintain a list-order.
|
|
return self.insert(len(self.items), item)
|
|
|
|
def get_axis(
|
|
self,
|
|
plot: PlotItem,
|
|
name: str,
|
|
|
|
) -> Optional[AxisItem]:
|
|
'''
|
|
Retrieve the named axis for overlayed ``plot`` or ``None``
|
|
if axis for that name is not shown.
|
|
|
|
'''
|
|
index = self.items.index(plot)
|
|
named = self._pi2axes[name]
|
|
return named.get(index)
|
|
|
|
def pop(
|
|
self,
|
|
item: PlotItem,
|
|
|
|
) -> PlotItem:
|
|
'''
|
|
Remove item and restack all axes in list-order.
|
|
|
|
'''
|
|
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] = []
|
|
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 len(self.overlays)
|
|
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)
|
|
|
|
def get_axes(
|
|
self,
|
|
name: str,
|
|
|
|
) -> list[AxisItem]:
|
|
'''
|
|
Retrieve all axes for all plots with ``name: str``.
|
|
|
|
If a particular overlay doesn't have a displayed named axis
|
|
then it is not delivered in the returned ``list``.
|
|
|
|
'''
|
|
axes = []
|
|
for plot in self.overlays:
|
|
axis = self.layout.get_axis(plot, name)
|
|
if axis:
|
|
axes.append(axis)
|
|
|
|
return axes
|
|
|
|
# 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
|