From 69368f20c21b79d550cf9bfe5ffe89b3a7109817 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 22 Dec 2023 11:47:31 -0500 Subject: [PATCH] Finally fix our `SelectRect` for use with cursor.. Turns out using the `.setRect()` method was the main cause of the issue (though still don't really understand how or why) and this instead adopts verbatim the code from `pg.ViewBox.updateScaleBox()` which uses a scaling transform to set the rect for the "zoom scale box" thingy. Further add a shite ton more improvements and interface tweaks in support of the new remote-annotation control msging subsys: - re-impl `.set_scen_pos()` to expect `QGraphicsScene` coordinates (i.e. passed from the interaction loop and pass scene `QPointF`s from `ViewBox.mouseDragEvent()` using the `MouseDragEvent.scenePos()` and friends; this is required to properly use the transform setting approach to resize the select-rect as mentioned above. - add `as_point()` converter to maybe-cast python `tuple[float, float]` inputs (prolly from IPC msgs) to equivalent `QPointF`s. - add a ton more detailed Qt-obj-related typing throughout our deriv. - call `.add_to_view()` from init so that wtv view is passed in during instantiation is always set as the `.vb` after creation. - factor the (proxy widget) label creation into a new `.init_label()` so that both the `set_scen/view_pos()` methods can call it and just generally decouple rect-pos mods from label content mods. --- piker/ui/_editors.py | 253 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 201 insertions(+), 52 deletions(-) diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py index 8fb7bc7b..2210d874 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -21,7 +21,8 @@ Higher level annotation editors. from __future__ import annotations from collections import defaultdict from typing import ( - TYPE_CHECKING + Sequence, + TYPE_CHECKING, ) import pyqtgraph as pg @@ -31,24 +32,36 @@ from pyqtgraph import ( QtCore, QtWidgets, ) +from PyQt5.QtCore import ( + QPointF, + QRectF, +) from PyQt5.QtGui import ( QColor, + QTransform, ) from PyQt5.QtWidgets import ( + QGraphicsProxyWidget, + QGraphicsScene, QLabel, ) from pyqtgraph import functions as fn -from PyQt5.QtCore import QPointF import numpy as np from piker.types import Struct -from ._style import hcolor, _font +from ._style import ( + hcolor, + _font, +) from ._lines import LevelLine from ..log import get_logger if TYPE_CHECKING: - from ._chart import GodWidget + from ._chart import ( + GodWidget, + ChartPlotWidget, + ) from ._interaction import ChartView @@ -66,7 +79,7 @@ class ArrowEditor(Struct): uid: str, x: float, y: float, - color='default', + color: str = 'default', pointing: str | None = None, ) -> pg.ArrowItem: @@ -252,27 +265,56 @@ class LineEditor(Struct): return lines -class SelectRect(QtWidgets.QGraphicsRectItem): +def as_point( + pair: Sequence[float, float] | QPointF, +) -> list[QPointF, QPointF]: + ''' + Case any input tuple of floats to a a list of `QPoint` objects + for use in Qt geometry routines. + ''' + if isinstance(pair, QPointF): + return pair + + return QPointF(pair[0], pair[1]) + + +# TODO: maybe implement better, something something RectItemProxy?? +# -[ ] dig into details of how proxy's work? +# https://doc.qt.io/qt-5/qgraphicsscene.html#addWidget +# -[ ] consider using `.addRect()` maybe? + +class SelectRect(QtWidgets.QGraphicsRectItem): + ''' + A data-view "selection rectangle": the most fundamental + geometry for annotating data views. + + - https://doc.qt.io/qt-5/qgraphicsrectitem.html + - https://doc.qt.io/qt-6/qgraphicsrectitem.html + + ''' def __init__( self, viewbox: ViewBox, - color: str = 'dad_blue', + color: str | None = None, ) -> None: super().__init__(0, 0, 1, 1) # self.rbScaleBox = QGraphicsRectItem(0, 0, 1, 1) self.vb: ViewBox = viewbox - self._chart: 'ChartPlotWidget' = None # noqa - # override selection box color + self._chart: ChartPlotWidget | None = None # noqa + + # TODO: maybe allow this to be dynamic via a method? + #l override selection box color + color: str = color or 'dad_blue' color = QColor(hcolor(color)) + self.setPen(fn.mkPen(color, width=1)) color.setAlpha(66) self.setBrush(fn.mkBrush(color)) self.setZValue(1e9) self.hide() - self._label = None label = self._label = QLabel() label.setTextFormat(0) # markdown @@ -282,13 +324,15 @@ class SelectRect(QtWidgets.QGraphicsRectItem): QtCore.Qt.AlignLeft # | QtCore.Qt.AlignVCenter ) + label.hide() # always right after init # proxy is created after containing scene is initialized - self._label_proxy = None - self._abs_top_right = None + self._label_proxy: QGraphicsProxyWidget | None = None + self._abs_top_right: Point | None = None - # TODO: "swing %" might be handy here (data's max/min # % change) - self._contents = [ + # TODO: "swing %" might be handy here (data's max/min + # # % change)? + self._contents: list[str] = [ 'change: {pchng:.2f} %', 'range: {rng:.2f}', 'bars: {nbars}', @@ -298,25 +342,30 @@ class SelectRect(QtWidgets.QGraphicsRectItem): 'sigma: {std:.2f}', ] + self.add_to_view(viewbox) + def add_to_view( self, view: ChartView, ) -> None: ''' - Self-defined view hookup impl. + Self-defined view hookup impl which will + also re-assign the internal ref. ''' view.addItem( self, ignoreBounds=True, ) + if self.vb is not view: + self.vb = view @property - def chart(self) -> 'ChartPlotWidget': # noqa + def chart(self) -> ChartPlotWidget: # noqa return self._chart @chart.setter - def chart(self, chart: 'ChartPlotWidget') -> None: # noqa + def chart(self, chart: ChartPlotWidget) -> None: # noqa self._chart = chart chart.sigRangeChanged.connect(self.update_on_resize) palette = self._label.palette() @@ -340,8 +389,10 @@ class SelectRect(QtWidgets.QGraphicsRectItem): def set_scen_pos( self, - p1: QPointF, - p2: QPointF + scen_p1: QPointF, + scen_p2: QPointF, + + update_label: bool = True, ) -> None: ''' @@ -350,16 +401,50 @@ class SelectRect(QtWidgets.QGraphicsRectItem): match. ''' - # map to view coords - self.set_view_pos( - self.vb.mapToView(p1), - self.vb.mapToView(p2), + # NOTE XXX: apparently just setting it doesn't work!? + # i have no idea why but it's pretty weird we have to do + # this transform thing which was basically pulled verbatim + # from the `pg.ViewBox.updateScaleBox()` method. + view_rect: QRectF = self.vb.childGroup.mapRectFromScene( + QRectF( + scen_p1, + scen_p2, + ) ) + self.setPos(view_rect.topLeft()) + # XXX: does not work..!?!? + # https://doc.qt.io/qt-5/qgraphicsrectitem.html#setRect + # self.setRect(view_rect) + + tr = QTransform.fromScale( + view_rect.width(), + view_rect.height(), + ) + self.setTransform(tr) + + # XXX: never got this working, was always offset + # / transformed completely wrong (and off to the far right + # from the cursor?) + # self.set_view_pos( + # view_rect=view_rect, + # # self.vwqpToView(p1), + # # self.vb.mapToView(p2), + # # start_pos=self.vb.mapToScene(p1), + # # end_pos=self.vb.mapToScene(p2), + # ) + self.show() + + if update_label: + self.init_label(view_rect) def set_view_pos( self, - start_pos: QPointF, - end_pos: QPointF, + + start_pos: QPointF | Sequence[float, float] | None = None, + end_pos: QPointF | Sequence[float, float] | None = None, + view_rect: QRectF | None = None, + + update_label: bool = True, ) -> None: ''' @@ -368,27 +453,80 @@ class SelectRect(QtWidgets.QGraphicsRectItem): moved to match). ''' - # https://doc.qt.io/qt-5/qgraphicsproxywidget.html - if self._label_proxy is None: - self._label_proxy = self.vb.scene( - ).addWidget(self._label) + if self._chart is None: + raise RuntimeError( + 'You MUST assign a `SelectRect.chart: ChartPlotWidget`!' + ) - # map to view coords and update area - r = QtCore.QRectF(start_pos, end_pos) + if view_rect is None: + # ensure point casting + start_pos: QPointF = as_point(start_pos) + end_pos: QPointF = as_point(end_pos) - # old way; don't need right? - # lr = QtCore.QRectF(p1, p2) - # r = self.vb.childGroup.mapRectFromParent(lr) + # map to view coords and update area + view_rect = QtCore.QRectF( + start_pos, + end_pos, + ) - self.setPos(r.topLeft()) - self.resetTransform() - self.setRect(r) + self.setPos(view_rect.topLeft()) + + # NOTE: SERIOUSLY NO IDEA WHY THIS WORKS... + # but it does and all the other commented stuff above + # dint, dawg.. + + # self.resetTransform() + # self.setRect(view_rect) + + tr = QTransform.fromScale( + view_rect.width(), + view_rect.height(), + ) + self.setTransform(tr) + + if update_label: + self.init_label(view_rect) + + print( + 'SelectRect modify:\n' + f'QRectF: {view_rect}\n' + f'start_pos: {start_pos}\n' + f'end_pos: {end_pos}\n' + ) self.show() - y1, y2 = start_pos.y(), end_pos.y() - x1, x2 = start_pos.x(), end_pos.x() + def init_label( + self, + view_rect: QRectF, + ) -> QLabel: - # TODO: heh, could probably use a max-min streamin algo here too + # should be init-ed in `.__init__()` + label: QLabel = self._label + cv: ChartView = self.vb + + # https://doc.qt.io/qt-5/qgraphicsproxywidget.html + if self._label_proxy is None: + scen: QGraphicsScene = cv.scene() + # NOTE: specifically this is passing a widget + # pointer to the scene's `.addWidget()` as per, + # https://doc.qt.io/qt-5/qgraphicsproxywidget.html#embedding-a-widget-with-qgraphicsproxywidget + self._label_proxy: QGraphicsProxyWidget = scen.addWidget(label) + + # get label startup coords + tl: QPointF = view_rect.topLeft() + br: QPointF = view_rect.bottomRight() + + x1, y1 = tl.x(), tl.y() + x2, y2 = br.x(), br.y() + + # TODO: to remove, previous label corner point unpacking + # x1, y1 = start_pos.x(), start_pos.y() + # x2, y2 = end_pos.x(), end_pos.y() + # y1, y2 = start_pos.y(), end_pos.y() + # x1, x2 = start_pos.x(), end_pos.x() + + # TODO: heh, could probably use a max-min streamin algo + # here too? _, xmn = min(y1, y2), min(x1, x2) ymx, xmx = max(y1, y2), max(x1, x2) @@ -398,26 +536,35 @@ class SelectRect(QtWidgets.QGraphicsRectItem): ixmn, ixmx = round(xmn), round(xmx) nbars = ixmx - ixmn + 1 - chart = self._chart - data = chart.get_viz(chart.name).shm.array[ixmn:ixmx] + chart: ChartPlotWidget = self._chart + data: np.ndarray = chart.get_viz( + chart.name + ).shm.array[ixmn:ixmx] if len(data): - std = data['close'].std() - dmx = data['high'].max() - dmn = data['low'].min() + std: float = data['close'].std() + dmx: float = data['high'].max() + dmn: float = data['low'].min() else: dmn = dmx = std = np.nan # update label info - self._label.setText('\n'.join(self._contents).format( - pchng=pchng, rng=rng, nbars=nbars, - std=std, dmx=dmx, dmn=dmn, + label.setText('\n'.join(self._contents).format( + pchng=pchng, + rng=rng, + nbars=nbars, + std=std, + dmx=dmx, + dmn=dmn, )) # print(f'x2, y2: {(x2, y2)}') # print(f'xmn, ymn: {(xmn, ymx)}') - label_anchor = Point(xmx + 2, ymx) + label_anchor = Point( + xmx + 2, + ymx, + ) # XXX: in the drag bottom-right -> top-left case we don't # want the label to overlay the box. @@ -426,9 +573,11 @@ class SelectRect(QtWidgets.QGraphicsRectItem): # # label_anchor = Point(x2, y2 + self._label.height()) # label_anchor = Point(xmn, ymn) - self._abs_top_right = label_anchor - self._label_proxy.setPos(self.vb.mapFromView(label_anchor)) - # self._label.show() + self._abs_top_right: Point = label_anchor + self._label_proxy.setPos( + cv.mapFromView(label_anchor) + ) + label.show() def clear(self): '''