From 62517c1662e3c9aa8c72668a7cd046f13ecb56bd Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 16 Jul 2021 07:45:42 -0400 Subject: [PATCH 001/178] Add user defined anchor support to label; reorg mod --- piker/ui/_interaction.py | 1 - piker/ui/_label.py | 363 +++++++++++++++++++++------------------ 2 files changed, 195 insertions(+), 169 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 18cada9f..89cc9474 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -32,7 +32,6 @@ import trio from ..log import get_logger from ._style import _min_points_to_show from ._editors import SelectRect -from ._window import main_window log = get_logger(__name__) diff --git a/piker/ui/_label.py b/piker/ui/_label.py index f0a0ec31..7f381b1f 100644 --- a/piker/ui/_label.py +++ b/piker/ui/_label.py @@ -19,7 +19,7 @@ Non-shitty labels that don't re-invent the wheel. """ from inspect import isfunction -from typing import Callable +from typing import Callable, Optional import pyqtgraph as pg from PyQt5 import QtGui, QtWidgets @@ -31,6 +31,200 @@ from ._style import ( ) +class Label: + """ + A plain ol' "scene label" using an underlying ``QGraphicsTextItem``. + + After hacking for many days on multiple "label" systems inside + ``pyqtgraph`` yet again we're left writing our own since it seems + all of those are over complicated, ad-hoc, transform-mangling, + messes which can't accomplish the simplest things via their inputs + (such as pinning to the left hand side of a view box). + + Here we do the simple thing where the label uses callables to figure + out the (x, y) coordinate "pin point": nice and simple. + + This type is another effort (see our graphics) to start making + small, re-usable label components that can actually be used to build + production grade UIs... + + """ + + def __init__( + + self, + view: pg.ViewBox, + fmt_str: str, + + color: str = 'bracket', + x_offset: float = 0, + font_size: str = 'small', + opacity: float = 0.666, + fields: dict = {}, + update_on_range_change: bool = True, + + ) -> None: + + vb = self.vb = view + self._fmt_str = fmt_str + self._view_xy = QPointF(0, 0) + + self.scene_anchor: Optional[Callable[..., QPointF]] = None + + self._x_offset = x_offset + + txt = self.txt = QtWidgets.QGraphicsTextItem() + vb.scene().addItem(txt) + + # configure font size based on DPI + dpi_font = DpiAwareFont( + font_size=font_size, + ) + dpi_font.configure_to_dpi() + txt.setFont(dpi_font.font) + + txt.setOpacity(opacity) + + # register viewbox callbacks + if update_on_range_change: + vb.sigRangeChanged.connect(self.on_sigrange_change) + + self._hcolor: str = '' + self.color = color + + self.fields = fields + self.orient_v = 'bottom' + + self._anchor_func = self.txt.pos().x + + # not sure if this makes a diff + self.txt.setCacheMode(QtWidgets.QGraphicsItem.DeviceCoordinateCache) + + # TODO: edit and selection support + # https://doc.qt.io/qt-5/qt.html#TextInteractionFlag-enum + # self.setTextInteractionFlags(QtGui.Qt.TextEditorInteraction) + + @property + def color(self): + return self._hcolor + + @color.setter + def color(self, color: str) -> None: + self.txt.setDefaultTextColor(pg.mkColor(hcolor(color))) + self._hcolor = color + + def update(self) -> None: + '''Update this label either by invoking its + user defined anchoring function, or by positioning + to the last recorded data view coordinates. + + ''' + # move label in scene coords to desired position + anchor = self.scene_anchor + if anchor: + self.txt.setPos(anchor()) + + else: + # position based on last computed view coordinate + self.set_view_pos(self._view_xy.y()) + + def on_sigrange_change(self, vr, r) -> None: + return self.update() + + @property + def w(self) -> float: + return self.txt.boundingRect().width() + + @property + def h(self) -> float: + return self.txt.boundingRect().height() + + def vbr(self) -> QRectF: + return self.vb.boundingRect() + + def set_x_anchor_func( + self, + func: Callable, + ) -> None: + assert isinstance(func(), float) + self._anchor_func = func + + def set_view_pos( + self, + + y: float, + x: Optional[float] = None, + + ) -> None: + + if x is None: + scene_x = self._anchor_func() or self.txt.pos().x() + x = self.vb.mapToView(QPointF(scene_x, scene_x)).x() + + # get new (inside the) view coordinates / position + self._view_xy = QPointF(x, y) + + # map back to the outer UI-land "scene" coordinates + s_xy = self.vb.mapFromView(self._view_xy) + + if self.orient_v == 'top': + s_xy = QPointF(s_xy.x(), s_xy.y() - self.h) + + # move label in scene coords to desired position + self.txt.setPos(s_xy) + + assert s_xy == self.txt.pos() + + # def orient_on(self, h: str, v: str) -> None: + # pass + + @property + def fmt_str(self) -> str: + return self._fmt_str + + @fmt_str.setter + def fmt_str(self, fmt_str: str) -> None: + self._fmt_str = fmt_str + + def format( + self, + **fields: dict + + ) -> str: + + out = {} + + # this is hacky support for single depth + # calcs of field data from field data + # ex. to calculate a $value = price * size + for k, v in fields.items(): + + if isfunction(v): + out[k] = v(fields) + + else: + out[k] = v + + text = self._fmt_str.format(**out) + + # for large numbers with a thousands place + text = text.replace(',', ' ') + + self.txt.setPlainText(text) + + def render(self) -> None: + self.format(**self.fields) + + def show(self) -> None: + self.txt.show() + + def hide(self) -> None: + self.txt.hide() + + def delete(self) -> None: + self.vb.scene().removeItem(self.txt) + + def vbr_left(label) -> Callable[..., float]: """Return a closure which gives the scene x-coordinate for the leftmost point of the containing view box. @@ -85,170 +279,3 @@ def right_axis( return ryaxis.pos().x() # + axis_offset - 2 return on_axis - - -class Label: - """ - A plain ol' "scene label" using an underlying ``QGraphicsTextItem``. - - After hacking for many days on multiple "label" systems inside - ``pyqtgraph`` yet again we're left writing our own since it seems - all of those are over complicated, ad-hoc, transform-mangling, - messes which can't accomplish the simplest things via their inputs - (such as pinning to the left hand side of a view box). - - Here we do the simple thing where the label uses callables to figure - out the (x, y) coordinate "pin point": nice and simple. - - This type is another effort (see our graphics) to start making - small, re-usable label components that can actually be used to build - production grade UIs... - - """ - - def __init__( - - self, - view: pg.ViewBox, - - fmt_str: str, - color: str = 'bracket', - x_offset: float = 0, - font_size: str = 'small', - opacity: float = 0.666, - fields: dict = {} - - ) -> None: - - vb = self.vb = view - self._fmt_str = fmt_str - self._view_xy = QPointF(0, 0) - - self._x_offset = x_offset - - txt = self.txt = QtWidgets.QGraphicsTextItem() - vb.scene().addItem(txt) - - # configure font size based on DPI - dpi_font = DpiAwareFont( - font_size=font_size, - ) - dpi_font.configure_to_dpi() - txt.setFont(dpi_font.font) - - txt.setOpacity(opacity) - - # register viewbox callbacks - vb.sigRangeChanged.connect(self.on_sigrange_change) - - self._hcolor: str = '' - self.color = color - - self.fields = fields - self.orient_v = 'bottom' - - self._anchor_func = self.txt.pos().x - - # not sure if this makes a diff - self.txt.setCacheMode(QtWidgets.QGraphicsItem.DeviceCoordinateCache) - - # TODO: edit and selection support - # https://doc.qt.io/qt-5/qt.html#TextInteractionFlag-enum - # self.setTextInteractionFlags(QtGui.Qt.TextEditorInteraction) - - @property - def color(self): - return self._hcolor - - @color.setter - def color(self, color: str) -> None: - self.txt.setDefaultTextColor(pg.mkColor(hcolor(color))) - self._hcolor = color - - def on_sigrange_change(self, vr, r) -> None: - self.set_view_y(self._view_xy.y()) - - @property - def w(self) -> float: - return self.txt.boundingRect().width() - - @property - def h(self) -> float: - return self.txt.boundingRect().height() - - def vbr(self) -> QRectF: - return self.vb.boundingRect() - - def set_x_anchor_func( - self, - func: Callable, - ) -> None: - assert isinstance(func(), float) - self._anchor_func = func - - def set_view_y( - self, - y: float, - ) -> None: - - scene_x = self._anchor_func() or self.txt.pos().x() - - # get new (inside the) view coordinates / position - self._view_xy = QPointF( - self.vb.mapToView(QPointF(scene_x, scene_x)).x(), - y, - ) - - # map back to the outer UI-land "scene" coordinates - s_xy = self.vb.mapFromView(self._view_xy) - - if self.orient_v == 'top': - s_xy = QPointF(s_xy.x(), s_xy.y() - self.h) - - # move label in scene coords to desired position - self.txt.setPos(s_xy) - - assert s_xy == self.txt.pos() - - def orient_on(self, h: str, v: str) -> None: - pass - - @property - def fmt_str(self) -> str: - return self._fmt_str - - @fmt_str.setter - def fmt_str(self, fmt_str: str) -> None: - self._fmt_str = fmt_str - - def format(self, **fields: dict) -> str: - - out = {} - - # this is hacky support for single depth - # calcs of field data from field data - # ex. to calculate a $value = price * size - for k, v in fields.items(): - if isfunction(v): - out[k] = v(fields) - else: - out[k] = v - - text = self._fmt_str.format(**out) - - # for large numbers with a thousands place - text = text.replace(',', ' ') - - self.txt.setPlainText(text) - - def render(self) -> None: - self.format(**self.fields) - - def show(self) -> None: - self.txt.show() - - def hide(self) -> None: - self.txt.hide() - - def delete(self) -> None: - self.vb.scene().removeItem(self.txt) From 791fd23524c88849d461e78163d9e347e9d069e6 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 16 Jul 2021 09:26:06 -0400 Subject: [PATCH 002/178] Remove `LevelLine.add_label()`, add dynamic pp marker label --- piker/ui/_lines.py | 338 ++++++++++++++++++++++++--------------------- 1 file changed, 180 insertions(+), 158 deletions(-) diff --git a/piker/ui/_lines.py b/piker/ui/_lines.py index 2f83ec7f..bdcc0585 100644 --- a/piker/ui/_lines.py +++ b/piker/ui/_lines.py @@ -36,12 +36,6 @@ from ._style import hcolor, _font # https://stackoverflow.com/questions/26156486/determine-bounding-rect-of-line-in-qt class LevelLine(pg.InfiniteLine): - # TODO: fill in these slots for orders - # available parent signals - # sigDragged(self) - # sigPositionChangeFinished(self) - # sigPositionChanged(self) - def __init__( self, chart: 'ChartPlotWidget', # type: ignore # noqa @@ -63,6 +57,9 @@ class LevelLine(pg.InfiniteLine): ) -> None: + # TODO: at this point it's probably not worth the inheritance + # any more since we've reimplemented ``.pain()`` among other + # things.. super().__init__( movable=movable, angle=0, @@ -97,7 +94,7 @@ class LevelLine(pg.InfiniteLine): # list of labels anchored at one of the 2 line endpoints # inside the viewbox - self._labels: List[(int, Label)] = [] + self._labels: List[Label] = [] self._markers: List[(int, Label)] = [] # whenever this line is moved trigger label updates @@ -143,52 +140,6 @@ class LevelLine(pg.InfiniteLine): hoverpen.setWidth(2) self.hoverPen = hoverpen - def add_label( - self, - - # by default we only display the line's level value - # in the label - fmt_str: str = ( - '{level:,.{level_digits}f}' - ), - side: str = 'right', - side_of_axis: str = 'left', - x_offset: float = 0, - - color: str = None, - bg_color: str = None, - avoid_book: bool = True, - - **label_kwargs, - ) -> Label: - """Add a ``LevelLabel`` anchored at one of the line endpoints in view. - - """ - label = Label( - view=self.getViewBox(), - fmt_str=fmt_str, - color=self.color, - ) - - # set anchor callback - if side == 'right': - label.set_x_anchor_func( - right_axis( - self._chart, - label, - side=side_of_axis, - offset=x_offset, - avoid_book=avoid_book, - ) - ) - - elif side == 'left': - label.set_x_anchor_func(vbr_left(label)) - - self._labels.append((side, label)) - - return label - def on_pos_change( self, line: 'LevelLine', # noqa @@ -201,9 +152,11 @@ class LevelLine(pg.InfiniteLine): def update_labels( self, fields_data: dict, + ) -> None: - for at, label in self._labels: + for label in self._labels: + label.color = self.color # print(f'color is {self.color}') @@ -211,18 +164,18 @@ class LevelLine(pg.InfiniteLine): level = fields_data.get('level') if level: - label.set_view_y(level) + label.set_view_pos(y=level) label.render() self.update() def hide_labels(self) -> None: - for at, label in self._labels: + for label in self._labels: label.hide() def show_labels(self) -> None: - for at, label in self._labels: + for label in self._labels: label.show() def set_level( @@ -316,9 +269,10 @@ class LevelLine(pg.InfiniteLine): """ scene = self.scene() if scene: - for at, label in self._labels: + for label in self._labels: label.delete() + # gc managed labels? self._labels.clear() if self._marker: @@ -406,6 +360,7 @@ class LevelLine(pg.InfiniteLine): self._marker.setPos( QPointF(marker_right, self.scene_y()) ) + self._marker.label.update() elif not self.use_marker_margin: # basically means **don't** shorten the line with normally @@ -439,7 +394,8 @@ class LevelLine(pg.InfiniteLine): def add_marker( self, path: QtWidgets.QGraphicsPathItem, - ) -> None: + + ) -> QtWidgets.QGraphicsPathItem: # add path to scene self.getViewBox().scene().addItem(path) @@ -453,6 +409,7 @@ class LevelLine(pg.InfiniteLine): # y_in_sc = chart._vb.mapFromView(Point(0, self.value())).y() path.setPos(QPointF(rsc, self.scene_y())) + return path # self.update() def hoverEvent(self, ev): @@ -471,6 +428,9 @@ class LevelLine(pg.InfiniteLine): if self._moh: self.show_markers = True + if self._marker: + self._marker.show() + # highlight if so configured if self._hoh: @@ -514,11 +474,14 @@ class LevelLine(pg.InfiniteLine): if self._moh: self.show_markers = False + if self._marker: + self._marker.hide() + if self not in cur._trackers: cur.show_xhair(y_label_level=self.value()) if not self._always_show_labels: - for at, label in self._labels: + for label in self._labels: label.hide() label.txt.update() # label.unhighlight() @@ -531,24 +494,19 @@ class LevelLine(pg.InfiniteLine): def level_line( chart: 'ChartPlotWidget', # noqa level: float, - color: str = 'default', - - # whether or not the line placed in view should highlight - # when moused over (aka "hovered") - hl_on_hover: bool = True, # line style dotted: bool = False, + color: str = 'default', + + # ux + hl_on_hover: bool = True, # label fields and options digits: int = 1, - always_show_labels: bool = False, - add_label: bool = True, - orient_v: str = 'bottom', - **kwargs, ) -> LevelLine: @@ -580,14 +538,31 @@ def level_line( if add_label: - label = line.add_label( - side='right', - opacity=1, - x_offset=0, - avoid_book=False, - ) - label.orient_v = orient_v + label = Label( + view=line.getViewBox(), + + # by default we only display the line's level value + # in the label + fmt_str=('{level:,.{level_digits}f}'), + color=color, + ) + + # anchor to right side (of view ) label + label.set_x_anchor_func( + right_axis( + chart, + label, + side='left', # side of axis + offset=0, + avoid_book=False, + ) + ) + + # add to label set which will be updated on level changes + line._labels.append(label) + + label.orient_v = orient_v line.update_labels({'level': level, 'level_digits': 2}) label.render() @@ -600,6 +575,7 @@ def level_line( def order_line( + chart, level: float, level_digits: float, @@ -651,7 +627,8 @@ def order_line( # resetting the graphics item transform intermittently # XXX: this is our new approach but seems slower? - # line.add_marker(mk_marker(marker_style, marker_size)) + # path = line.add_marker(mk_marker(marker_style, marker_size)) + # assert line._marker == path assert not line.markers @@ -672,14 +649,22 @@ def order_line( orient_v = 'top' if action == 'sell' else 'bottom' if action == 'alert': - # completely different labelling for alerts - fmt_str = 'alert => {level}' + + llabel = Label( + + view=line.getViewBox(), + color=line.color, + + # completely different labelling for alerts + fmt_str='alert => {level}', + ) # for now, we're just duplicating the label contents i guess.. - llabel = line.add_label( - side='left', - fmt_str=fmt_str, - ) + line._labels.append(llabel) + + # anchor to left side of view / line + llabel.set_x_anchor_func(vbr_left(llabel)) + llabel.fields = { 'level': level, 'level_digits': level_digits, @@ -689,31 +674,30 @@ def order_line( llabel.show() else: - # # left side label - # llabel = line.add_label( - # side='left', - # fmt_str=' {exec_type}-{order_type}:\n ${$value}', - # ) - # llabel.fields = { - # 'order_type': order_type, - # 'level': level, - # '$value': lambda f: f['level'] * f['size'], - # 'size': size, - # 'exec_type': exec_type, - # } - # llabel.orient_v = orient_v - # llabel.render() - # llabel.show() - # right before L1 label - rlabel = line.add_label( - side='right', - side_of_axis='left', - x_offset=4*marker_size, - fmt_str=( - '{size:.{size_digits}f} ' - ), + rlabel = Label( + + view=line.getViewBox(), + + # display the order pos size + fmt_str=('{size:.{size_digits}f} '), + color=line.color, ) + + # set anchor callback + # right side label by default + rlabel.set_x_anchor_func( + right_axis( + chart, + rlabel, + side='left', # side of axis + offset=4*marker_size, + avoid_book=True, + ) + ) + + line._labels.append(rlabel) + rlabel.fields = { 'size': size, 'size_digits': size_digits, @@ -742,69 +726,23 @@ def position_line( execution submitted to the EMS via the chart's "order mode". """ + hcolor = 'default_light' + line = level_line( chart, level, - color='default_light', + color=hcolor, add_label=False, hl_on_hover=False, movable=False, - always_show_labels=False, hide_xhair_on_hover=False, use_marker_margin=True, + only_show_markers_on_hover=False, + always_show_labels=True, ) # hide position marker when out of view (for now) vb = line.getViewBox() - def update_pp_nav(chartview): - vr = vb.state['viewRange'] - ymn, ymx = vr[1] - level = line.value() - path = line._marker - - # provide "nav hub" like indicator for where - # the position is on the y-dimension - # print(path._height) - # print(vb.shape()) - # print(vb.boundingRect()) - # print(vb.height()) - _, marker_right, _ = line.marker_right_points() - - if level > ymx: # pin to top of view - path.setPos( - QPointF( - marker_right, - 2 + path._height, - ) - ) - - elif level < ymn: # pin to bottom of view - path.setPos( - QPointF( - marker_right, - vb.height() - 16 + path._height, - ) - ) - - else: - # pp line is viewable so show marker - line._marker.show() - - vb.sigYRangeChanged.connect(update_pp_nav) - - rlabel = line.add_label( - side='right', - fmt_str='{direction}: {size} -> ${$:.2f}', - ) - rlabel.fields = { - 'direction': 'long' if size > 0 else 'short', - '$': size * level, - 'size': size, - } - rlabel.orient_v = orient_v - rlabel.render() - rlabel.show() - # arrow marker # scale marker size with dpi-aware font size font_size = _font.font.pixelSize() @@ -819,8 +757,40 @@ def position_line( arrow_path = mk_marker(style, size=arrow_size) + path_br = arrow_path.mapToScene( + arrow_path.path() + ).boundingRect() + # monkey-cache height for sizing on pp nav-hub - arrow_path._height = arrow_path.boundingRect().height() + arrow_path._height = path_br.height() + + arrow_path._width = path_br.width() + # wp = QPointF(w, w) + + marker_label = Label( + view=vb, + fmt_str='pp', + color=hcolor, + update_on_range_change=False, + ) + arrow_path.label = marker_label + + # def arrow_br(): + # # get actual arrow graphics path + # path_br = arrow_path.mapToScene( + # arrow_path.path() + # ).boundingRect() + + # # vb.locate(arrow_path) #, children=True) + + # return path_br.bottomRight() - QPointF(0, marker_label.h / 2) + + # marker_label.scene_anchor = arrow_br + + line._labels.append(marker_label) + + marker_label.render() + marker_label.show() # XXX: uses new marker drawing approach line.add_marker(arrow_path) @@ -829,4 +799,56 @@ def position_line( # sanity check line.update_labels({'level': level}) + def update_pp_nav(chartview): + '''Show a pp off-screen indicator when order mode is activated. + + ''' + vr = vb.state['viewRange'] + ymn, ymx = vr[1] + level = line.value() + + path = line._marker + label = path.label + + # get actual arrow-marker graphics path + path_br = path.mapToScene( + path.path() + ).boundingRect() + + # provide "nav hub" like indicator for where + # the position is on the y-dimension + + _, marker_right, _ = line.marker_right_points() + + if level > ymx: # pin to top of view + path.setPos( + QPointF( + marker_right, + path._height/3, + ) + ) + + elif level < ymn: # pin to bottom of view + + path.setPos( + QPointF( + marker_right, + vb.height() - 4/3*path._height, + ) + ) + + # adjust marker labels to be above bottom of view + label.txt.setPos(path_br.topRight() - QPointF(0, label.h / 2)) + + else: + # pp line is viewable so show marker normally + line._marker.show() + + # place label at bottom right of pp marker + label.txt.setPos(path_br.bottomRight() - QPointF(0, label.h / 2)) + + line.show_labels() + + vb.sigRangeChanged.connect(update_pp_nav) + return line From 3aab6d67e9195aa75bacf781a6d60741ae8b0007 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 16 Jul 2021 11:40:56 -0400 Subject: [PATCH 003/178] Use label anchor --- piker/ui/_label.py | 2 ++ piker/ui/_lines.py | 48 +++++++++++++++++++--------------------------- 2 files changed, 22 insertions(+), 28 deletions(-) diff --git a/piker/ui/_label.py b/piker/ui/_label.py index 7f381b1f..e0a4543b 100644 --- a/piker/ui/_label.py +++ b/piker/ui/_label.py @@ -225,6 +225,8 @@ class Label: self.vb.scene().removeItem(self.txt) +# anchoring helper funcs + def vbr_left(label) -> Callable[..., float]: """Return a closure which gives the scene x-coordinate for the leftmost point of the containing view box. diff --git a/piker/ui/_lines.py b/piker/ui/_lines.py index bdcc0585..f9a4f176 100644 --- a/piker/ui/_lines.py +++ b/piker/ui/_lines.py @@ -360,6 +360,8 @@ class LevelLine(pg.InfiniteLine): self._marker.setPos( QPointF(marker_right, self.scene_y()) ) + # TODO: make this label update part of a scene-aware-marker + # composed annotation self._marker.label.update() elif not self.use_marker_margin: @@ -763,9 +765,7 @@ def position_line( # monkey-cache height for sizing on pp nav-hub arrow_path._height = path_br.height() - arrow_path._width = path_br.width() - # wp = QPointF(w, w) marker_label = Label( view=vb, @@ -775,17 +775,18 @@ def position_line( ) arrow_path.label = marker_label - # def arrow_br(): - # # get actual arrow graphics path - # path_br = arrow_path.mapToScene( - # arrow_path.path() - # ).boundingRect() + def arrow_tr(): + # get actual arrow graphics path + path_br = arrow_path.mapToScene( + arrow_path.path() + ).boundingRect() - # # vb.locate(arrow_path) #, children=True) + # vb.locate(arrow_path) #, children=True) - # return path_br.bottomRight() - QPointF(0, marker_label.h / 2) + return path_br.topRight() - QPointF(0, marker_label.h / 3) - # marker_label.scene_anchor = arrow_br + + marker_label.scene_anchor = arrow_tr line._labels.append(marker_label) @@ -807,13 +808,9 @@ def position_line( ymn, ymx = vr[1] level = line.value() - path = line._marker - label = path.label + marker = line._marker + label = marker.label - # get actual arrow-marker graphics path - path_br = path.mapToScene( - path.path() - ).boundingRect() # provide "nav hub" like indicator for where # the position is on the y-dimension @@ -821,33 +818,28 @@ def position_line( _, marker_right, _ = line.marker_right_points() if level > ymx: # pin to top of view - path.setPos( + marker.setPos( QPointF( marker_right, - path._height/3, + marker._height/3, ) ) elif level < ymn: # pin to bottom of view - path.setPos( + marker.setPos( QPointF( marker_right, - vb.height() - 4/3*path._height, + vb.height() - 4/3*marker._height, ) ) - # adjust marker labels to be above bottom of view - label.txt.setPos(path_br.topRight() - QPointF(0, label.h / 2)) - else: # pp line is viewable so show marker normally - line._marker.show() + marker.update() - # place label at bottom right of pp marker - label.txt.setPos(path_br.bottomRight() - QPointF(0, label.h / 2)) - - line.show_labels() + # re-anchor label (i.e. trigger call of ``arrow_tr()`` from above + label.update() vb.sigRangeChanged.connect(update_pp_nav) From 94d3f6770722c83868f75ebf8b236d096a0096a0 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 16 Jul 2021 12:31:05 -0400 Subject: [PATCH 004/178] Move marker level-line-positioning anchor to new module --- piker/ui/_anchors.py | 40 ++++++++++++++++++++++++++++++++++++++++ piker/ui/_lines.py | 32 ++++++++------------------------ 2 files changed, 48 insertions(+), 24 deletions(-) create mode 100644 piker/ui/_anchors.py diff --git a/piker/ui/_anchors.py b/piker/ui/_anchors.py new file mode 100644 index 00000000..37d0af3a --- /dev/null +++ b/piker/ui/_anchors.py @@ -0,0 +1,40 @@ +# piker: trading gear for hackers +# Copyright (C) Tyler Goodlet (in stewardship for piker0) + +# 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 . + +''' +Anchor funtions for UI placement of annotions. + +''' + + +def marker_right_points( + + chart: 'ChartPlotWidget', # noqa + marker_size: int = 20, + +) -> (float, float, float): + + # chart = self._chart + l1_len = chart._max_l1_line_len + ryaxis = chart.getAxis('right') + + r_axis_x = ryaxis.pos().x() + up_to_l1_sc = r_axis_x - l1_len + + marker_right = up_to_l1_sc - (1.375 * 2 * marker_size) + line_end = marker_right - (6/16 * marker_size) + + return line_end, marker_right, r_axis_x diff --git a/piker/ui/_lines.py b/piker/ui/_lines.py index f9a4f176..df295b13 100644 --- a/piker/ui/_lines.py +++ b/piker/ui/_lines.py @@ -27,6 +27,7 @@ from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtCore import QPointF from ._annotate import mk_marker, qgo_draw_markers +from ._anchors import marker_right_points from ._label import Label, vbr_left, right_axis from ._style import hcolor, _font @@ -44,7 +45,7 @@ class LevelLine(pg.InfiniteLine): color: str = 'default', highlight_color: str = 'default_light', dotted: bool = False, - marker_size: int = 20, + # marker_size: int = 20, # UX look and feel opts always_show_labels: bool = False, @@ -74,7 +75,7 @@ class LevelLine(pg.InfiniteLine): self._hide_xhair_on_hover = hide_xhair_on_hover self._marker = None - self._default_mkr_size = marker_size + # self._default_mkr_size = marker_size self._moh = only_show_markers_on_hover self.show_markers: bool = True # presuming the line is hovered at init @@ -306,21 +307,6 @@ class LevelLine(pg.InfiniteLine): return up_to_l1_sc - def marker_right_points(self) -> (float, float, float): - - chart = self._chart - l1_len = chart._max_l1_line_len - ryaxis = chart.getAxis('right') - - r_axis_x = ryaxis.pos().x() - up_to_l1_sc = r_axis_x - l1_len - - size = self._default_mkr_size - marker_right = up_to_l1_sc - (1.375 * 2*size) - line_end = marker_right - (6/16 * size) - - return line_end, marker_right, r_axis_x - def paint( self, p: QtGui.QPainter, @@ -337,7 +323,7 @@ class LevelLine(pg.InfiniteLine): vb_left, vb_right = self._endPoints vb = self.getViewBox() - line_end, marker_right, r_axis_x = self.marker_right_points() + line_end, marker_right, r_axis_x = marker_right_points(self._chart) if self.show_markers and self.markers: @@ -785,7 +771,6 @@ def position_line( return path_br.topRight() - QPointF(0, marker_label.h / 3) - marker_label.scene_anchor = arrow_tr line._labels.append(marker_label) @@ -803,6 +788,9 @@ def position_line( def update_pp_nav(chartview): '''Show a pp off-screen indicator when order mode is activated. + This is like in fps games where you have a gps "nav" indicator + but your teammate is outside the range of view, except in 2D, on + the y-dimension. ''' vr = vb.state['viewRange'] ymn, ymx = vr[1] @@ -811,11 +799,7 @@ def position_line( marker = line._marker label = marker.label - - # provide "nav hub" like indicator for where - # the position is on the y-dimension - - _, marker_right, _ = line.marker_right_points() + _, marker_right, _ = marker_right_points(line._chart) if level > ymx: # pin to top of view marker.setPos( From e58a980786545086c44986d907a9417bfbf626c4 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 16 Jul 2021 14:50:59 -0400 Subject: [PATCH 005/178] Move all anchor funcs to new mod --- piker/ui/_anchors.py | 119 ++++++++++++++++++++++++++++++++++++++++++- piker/ui/_label.py | 58 --------------------- piker/ui/_lines.py | 52 ++++--------------- 3 files changed, 127 insertions(+), 102 deletions(-) diff --git a/piker/ui/_anchors.py b/piker/ui/_anchors.py index 37d0af3a..4a78f375 100644 --- a/piker/ui/_anchors.py +++ b/piker/ui/_anchors.py @@ -18,6 +18,11 @@ Anchor funtions for UI placement of annotions. ''' +from typing import Callable + +from PyQt5.QtCore import QPointF + +from ._label import Label def marker_right_points( @@ -26,8 +31,13 @@ def marker_right_points( marker_size: int = 20, ) -> (float, float, float): + '''Return x-dimension, y-axis-aware, level-line marker oriented scene values. - # chart = self._chart + X values correspond to set the end of a level line, end of + a paried level line marker, and the right most side of the "right" + axis respectively. + + ''' l1_len = chart._max_l1_line_len ryaxis = chart.getAxis('right') @@ -38,3 +48,110 @@ def marker_right_points( line_end = marker_right - (6/16 * marker_size) return line_end, marker_right, r_axis_x + + +def vbr_left( + label: Label, + +) -> Callable[..., float]: + """Return a closure which gives the scene x-coordinate for the + leftmost point of the containing view box. + + """ + return label.vbr().left + + +def right_axis( + + chart: 'ChartPlotWidget', # noqa + label: Label, + + side: str = 'left', + offset: float = 10, + avoid_book: bool = True, + # width: float = None, + +) -> Callable[..., float]: + '''Return a position closure which gives the scene x-coordinate for + the x point on the right y-axis minus the width of the label given + it's contents. + + ''' + ryaxis = chart.getAxis('right') + + if side == 'left': + + if avoid_book: + def right_axis_offset_by_w() -> float: + + # l1 spread graphics x-size + l1_len = chart._max_l1_line_len + + # sum of all distances "from" the y-axis + right_offset = l1_len + label.w + offset + + return ryaxis.pos().x() - right_offset + + else: + def right_axis_offset_by_w() -> float: + + return ryaxis.pos().x() - (label.w + offset) + + return right_axis_offset_by_w + + elif 'right': + + # axis_offset = ryaxis.style['tickTextOffset'][0] + + def on_axis() -> float: + + return ryaxis.pos().x() # + axis_offset - 2 + + return on_axis + + +def update_pp_nav( + + chartview: 'ChartView', # noqa + line: 'LevelLine', # noqa + +) -> None: + '''Show a pp off-screen indicator for a level label. + + This is like in fps games where you have a gps "nav" indicator + but your teammate is outside the range of view, except in 2D, on + the y-dimension. + + ''' + vr = chartview.state['viewRange'] + ymn, ymx = vr[1] + level = line.value() + + marker = line._marker + label = marker.label + + _, marker_right, _ = marker_right_points(line._chart) + + if level > ymx: # pin to top of view + marker.setPos( + QPointF( + marker_right, + marker._height/3, + ) + ) + + elif level < ymn: # pin to bottom of view + + marker.setPos( + QPointF( + marker_right, + chartview.height() - 4/3*marker._height, + ) + ) + + else: + # pp line is viewable so show marker normally + marker.update() + + # re-anchor label (i.e. trigger call of ``arrow_tr()`` from above + label.update() diff --git a/piker/ui/_label.py b/piker/ui/_label.py index e0a4543b..ea1fb4b6 100644 --- a/piker/ui/_label.py +++ b/piker/ui/_label.py @@ -223,61 +223,3 @@ class Label: def delete(self) -> None: self.vb.scene().removeItem(self.txt) - - -# anchoring helper funcs - -def vbr_left(label) -> Callable[..., float]: - """Return a closure which gives the scene x-coordinate for the - leftmost point of the containing view box. - - """ - return label.vbr().left - - -def right_axis( - - chart: 'ChartPlotWidget', # noqa - label: 'Label', # noqa - side: str = 'left', - offset: float = 10, - avoid_book: bool = True, - width: float = None, - -) -> Callable[..., float]: - """Return a position closure which gives the scene x-coordinate for - the x point on the right y-axis minus the width of the label given - it's contents. - - """ - ryaxis = chart.getAxis('right') - - if side == 'left': - - if avoid_book: - def right_axis_offset_by_w() -> float: - - # l1 spread graphics x-size - l1_len = chart._max_l1_line_len - - # sum of all distances "from" the y-axis - right_offset = l1_len + label.w + offset - - return ryaxis.pos().x() - right_offset - - else: - def right_axis_offset_by_w() -> float: - - return ryaxis.pos().x() - (label.w + offset) - - return right_axis_offset_by_w - - elif 'right': - - # axis_offset = ryaxis.style['tickTextOffset'][0] - - def on_axis() -> float: - - return ryaxis.pos().x() # + axis_offset - 2 - - return on_axis diff --git a/piker/ui/_lines.py b/piker/ui/_lines.py index df295b13..9e5fbd9c 100644 --- a/piker/ui/_lines.py +++ b/piker/ui/_lines.py @@ -18,6 +18,7 @@ Lines for orders, alerts, L2. """ +from functools import partial from math import floor from typing import Tuple, Optional, List @@ -27,8 +28,13 @@ from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtCore import QPointF from ._annotate import mk_marker, qgo_draw_markers -from ._anchors import marker_right_points -from ._label import Label, vbr_left, right_axis +from ._anchors import ( + marker_right_points, + vbr_left, + right_axis, + update_pp_nav, +) +from ._label import Label from ._style import hcolor, _font @@ -785,46 +791,6 @@ def position_line( # sanity check line.update_labels({'level': level}) - def update_pp_nav(chartview): - '''Show a pp off-screen indicator when order mode is activated. - - This is like in fps games where you have a gps "nav" indicator - but your teammate is outside the range of view, except in 2D, on - the y-dimension. - ''' - vr = vb.state['viewRange'] - ymn, ymx = vr[1] - level = line.value() - - marker = line._marker - label = marker.label - - _, marker_right, _ = marker_right_points(line._chart) - - if level > ymx: # pin to top of view - marker.setPos( - QPointF( - marker_right, - marker._height/3, - ) - ) - - elif level < ymn: # pin to bottom of view - - marker.setPos( - QPointF( - marker_right, - vb.height() - 4/3*marker._height, - ) - ) - - else: - # pp line is viewable so show marker normally - marker.update() - - # re-anchor label (i.e. trigger call of ``arrow_tr()`` from above - label.update() - - vb.sigRangeChanged.connect(update_pp_nav) + vb.sigRangeChanged.connect(partial(update_pp_nav, chartview=vb, line=line)) return line From 69091a894f510dca6a09d6281d01742f3728e2a5 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 16 Jul 2021 16:03:32 -0400 Subject: [PATCH 006/178] Move marker label anchor to anchors mod --- piker/ui/_anchors.py | 16 ++++++++++++++++ piker/ui/_lines.py | 18 +++++++----------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/piker/ui/_anchors.py b/piker/ui/_anchors.py index 4a78f375..5d3a17f3 100644 --- a/piker/ui/_anchors.py +++ b/piker/ui/_anchors.py @@ -21,6 +21,7 @@ Anchor funtions for UI placement of annotions. from typing import Callable from PyQt5.QtCore import QPointF +from PyQt5.QtGui import QGraphicsPathItem from ._label import Label @@ -155,3 +156,18 @@ def update_pp_nav( # re-anchor label (i.e. trigger call of ``arrow_tr()`` from above label.update() + + +def path_topright( + gpath: QGraphicsPathItem, + label: Label, # noqa + +) -> QPointF: + # get actual arrow graphics path + path_br = gpath.mapToScene( + gpath.path() + ).boundingRect() + + # vb.locate(arrow_path) #, children=True) + + return path_br.topRight() - QPointF(0, label.h / 3) diff --git a/piker/ui/_lines.py b/piker/ui/_lines.py index 9e5fbd9c..d354e6b9 100644 --- a/piker/ui/_lines.py +++ b/piker/ui/_lines.py @@ -26,6 +26,7 @@ import pyqtgraph as pg from pyqtgraph import Point, functions as fn from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtCore import QPointF +from PyQt5.QtGui import QGraphicsPathItem from ._annotate import mk_marker, qgo_draw_markers from ._anchors import ( @@ -33,6 +34,7 @@ from ._anchors import ( vbr_left, right_axis, update_pp_nav, + path_topright, ) from ._label import Label from ._style import hcolor, _font @@ -767,17 +769,11 @@ def position_line( ) arrow_path.label = marker_label - def arrow_tr(): - # get actual arrow graphics path - path_br = arrow_path.mapToScene( - arrow_path.path() - ).boundingRect() - - # vb.locate(arrow_path) #, children=True) - - return path_br.topRight() - QPointF(0, marker_label.h / 3) - - marker_label.scene_anchor = arrow_tr + marker_label.scene_anchor = partial( + path_topright, + gpath=arrow_path, + label=marker_label, + ) line._labels.append(marker_label) From d21112dcd79e59bb2fa390c08c7c076d5aa70ad4 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 16 Jul 2021 18:32:56 -0400 Subject: [PATCH 007/178] Drop the open ctx mng; add wip pp label --- piker/ui/_chart.py | 4 +- piker/ui/order_mode.py | 153 +++++++++++++++++++++++++---------------- 2 files changed, 97 insertions(+), 60 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 41647481..f1ade0f4 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -65,7 +65,7 @@ from .. import data from ..log import get_logger from ._exec import run_qtractor from ._interaction import ChartView -from .order_mode import start_order_mode +from .order_mode import run_order_mode from .. import fsp from ..data import feed @@ -1574,7 +1574,7 @@ async def display_symbol_data( linkedsplits ) - await start_order_mode(chart, symbol, provider, order_mode_started) + await run_order_mode(chart, symbol, provider, order_mode_started) async def load_provider_search( diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index c8c54087..b7035cec 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -18,23 +18,24 @@ Chart trading, the only way to scalp. """ -from contextlib import asynccontextmanager from dataclasses import dataclass, field from pprint import pformat import time from typing import Optional, Dict, Callable, Any import uuid -import pyqtgraph as pg from pydantic import BaseModel +import tractor import trio -from ._lines import LevelLine, position_line -from ._editors import LineEditor, ArrowEditor -from ._window import MultiStatus, main_window +from ._anchors import marker_right_points from ..clearing._client import open_ems, OrderBook from ..data._source import Symbol from ..log import get_logger +from ._editors import LineEditor, ArrowEditor +from ._label import Label +from ._lines import LevelLine, position_line +from ._window import MultiStatus, main_window log = get_logger(__name__) @@ -91,6 +92,10 @@ class OrderMode: lines: LineEditor arrows: ArrowEditor status_bar: MultiStatus + + # pp status info + label: Label + name: str = 'order' _colors = { @@ -381,50 +386,11 @@ class OrderMode: ) -@asynccontextmanager -async def open_order_mode( - symbol: Symbol, - chart: pg.PlotWidget, - book: OrderBook, -): - status_bar: MultiStatus = main_window().status_bar - view = chart._vb - lines = LineEditor(chart=chart) - arrows = ArrowEditor(chart, {}) - - log.info("Opening order mode") - - mode = OrderMode(chart, book, lines, arrows, status_bar) - view.mode = mode - - asset_type = symbol.type_key - - if asset_type == 'stock': - mode._size = 100.0 - - elif asset_type in ('future', 'option', 'futures_option'): - mode._size = 1.0 - - else: # to be safe - mode._size = 1.0 - - try: - yield mode - - finally: - # XXX special teardown handling like for ex. - # - cancelling orders if needed? - # - closing positions if desired? - # - switching special condition orders to safer/more reliable variants - log.info("Closing order mode") - - -async def start_order_mode( +async def run_order_mode( chart: 'ChartPlotWidget', # noqa symbol: Symbol, brokername: str, - started: trio.Event, ) -> None: @@ -436,19 +402,90 @@ async def start_order_mode( ''' done = chart.window().status_bar.open_status('starting order mode..') + book: OrderBook + trades_stream: tractor.MsgStream + positions: dict + # spawn EMS actor-service async with ( - open_ems(brokername, symbol) as (book, trades_stream, positions), - open_order_mode(symbol, chart, book) as order_mode, + + open_ems(brokername, symbol) as ( + book, + trades_stream, + positions + ), # # start async input handling for chart's view # # await godwidget._task_stack.enter_async_context( # chart._vb.open_async_input_handler(), ): + status_bar: MultiStatus = main_window().status_bar + view = chart._vb + lines = LineEditor(chart=chart) + arrows = ArrowEditor(chart, {}) + + log.info("Opening order mode") + + pp_label = Label( + view=view, + color='default_light', + + # this is "static" label + # update_on_range_change=False, + + fmt_str='\n'.join(( + 'size: {entry_count}', + '% port: {percent_of_port}', + # '$val: {base_unit_value}', + )), + fields={ + 'entry_count': 0, + 'percent_of_port': 0, + 'base_unit_value': 0, + }, + ) + pp_label.render() + + from PyQt5.QtCore import QPointF + + # order line endpoint anchor + def bottom_marker_right() -> QPointF: + + return QPointF( + marker_right_points(chart)[0] - pp_label.w, + view.height() - pp_label.h, + ) + + # TODO: position on botto if l1/book is on top side + pp_label.scene_anchor = bottom_marker_right + pp_label.hide() + + mode = OrderMode( + chart, + book, + lines, + arrows, + status_bar, + label=pp_label, + ) + + view.mode = mode + + asset_type = symbol.type_key + + # default entry sizing + if asset_type == 'stock': + mode._size = 100.0 + + elif asset_type in ('future', 'option', 'futures_option'): + mode._size = 1.0 + + else: # to be safe + mode._size = 1.0 # update any exising positions for sym, msg in positions.items(): - order_mode.on_position_update(msg) + mode.on_position_update(msg) def get_index(time: float): @@ -485,13 +522,13 @@ async def start_order_mode( 'position', ): # show line label once order is live - order_mode.on_position_update(msg) + mode.on_position_update(msg) continue resp = msg['resp'] oid = msg['oid'] - dialog = order_mode.dialogs[oid] + dialog = mode.dialogs[oid] # record message to dialog tracking dialog.msgs[oid] = msg @@ -502,7 +539,7 @@ async def start_order_mode( ): # show line label once order is live - order_mode.on_submit(oid) + mode.on_submit(oid) # resp to 'cancel' request or error condition # for action request @@ -512,7 +549,7 @@ async def start_order_mode( 'dark_cancelled' ): # delete level line from view - order_mode.on_cancel(oid) + mode.on_cancel(oid) elif resp in ( 'dark_triggered' @@ -524,23 +561,23 @@ async def start_order_mode( ): # should only be one "fill" for an alert # add a triangle and remove the level line - order_mode.on_fill( + mode.on_fill( oid, price=msg['trigger_price'], arrow_index=get_index(time.time()), ) - order_mode.lines.remove_line(uuid=oid) - await order_mode.on_exec(oid, msg) + mode.lines.remove_line(uuid=oid) + await mode.on_exec(oid, msg) # response to completed 'action' request for buy/sell elif resp in ( 'broker_executed', ): # right now this is just triggering a system alert - await order_mode.on_exec(oid, msg) + await mode.on_exec(oid, msg) if msg['brokerd_msg']['remaining'] == 0: - order_mode.lines.remove_line(uuid=oid) + mode.lines.remove_line(uuid=oid) # each clearing tick is responded individually elif resp in ('broker_filled',): @@ -554,7 +591,7 @@ async def start_order_mode( details = msg['brokerd_msg'] # TODO: some kinda progress system - order_mode.on_fill( + mode.on_fill( oid, price=details['price'], pointing='up' if action == 'buy' else 'down', From ff6ac6ba4f81ed006ac17662de15ce50ba1890f6 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 17 Jul 2021 16:17:36 -0400 Subject: [PATCH 008/178] Add label location description param for graphics path anchor --- piker/ui/_anchors.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/piker/ui/_anchors.py b/piker/ui/_anchors.py index 5d3a17f3..b04ea54a 100644 --- a/piker/ui/_anchors.py +++ b/piker/ui/_anchors.py @@ -158,16 +158,26 @@ def update_pp_nav( label.update() -def path_topright( +def gpath_pin( + gpath: QGraphicsPathItem, label: Label, # noqa + location_description: str = 'right-of-path-centered', + use_right_of_pp_label: bool = False, + ) -> QPointF: + # get actual arrow graphics path - path_br = gpath.mapToScene( - gpath.path() - ).boundingRect() + path_br = gpath.mapToScene(gpath.path()).boundingRect() # vb.locate(arrow_path) #, children=True) - return path_br.topRight() - QPointF(0, label.h / 3) + if location_description == 'right-of-path-centered': + return path_br.topRight() - QPointF(0, label.h / 3) + + elif location_description == 'below-path-left-aligned': + return path_br.bottomLeft() - QPointF(0, label.h / 6) + + elif location_description == 'below-path-right-aligned': + return path_br.bottomRight() - QPointF(label.w, label.h / 6) From 45d6682ae04885a7b04d3871a801a089cc707e12 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 19 Jul 2021 07:46:30 -0400 Subject: [PATCH 009/178] Update entry count on position msgs, draft a composite position info type --- piker/ui/order_mode.py | 114 ++++++++++++++++++++++++++++++++--------- 1 file changed, 90 insertions(+), 24 deletions(-) diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index b7035cec..b0fb5a79 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -113,28 +113,36 @@ class OrderMode: def on_position_update( self, - msg: dict, + + size: float, + price: float, + ) -> None: - print(f'Position update {msg}') - sym = self.chart._lc._symbol - if msg['symbol'].lower() not in sym.key: - return + line = self._position_line - size = msg['size'] + if line is None and size: - self._position.update(msg) - if self._position_line: - self._position_line.delete() - - if size != 0.0: + # create and show a pp line line = self._position_line = position_line( self.chart, - level=msg['avg_price'], + level=price, size=size, ) line.show() + elif line: + + if size != 0.0: + line.set_level(price) + line.update_labels({'size': size}) + line.show() + + else: + # remove pp line from view + line.delete() + self._position_line = None + def uuid(self) -> str: return str(uuid.uuid4()) @@ -386,6 +394,35 @@ class OrderMode: ) +class PositionInfo: + + line: LevelLine + pp_label: Label + size_label: Label + info_label: Label + info: dict + + def update( + self, + avg_price, + size, + + ) -> None: + + self.info['avg_price'] = avg_price + self.size_label.fields['size'] = size + self.info_label.fields['size'] = size + + +def position_info( + + price: float, + size: float + +) -> PositionInfo: + pass + + async def run_order_mode( chart: 'ChartPlotWidget', # noqa @@ -434,30 +471,38 @@ async def run_order_mode( # update_on_range_change=False, fmt_str='\n'.join(( - 'size: {entry_count}', - '% port: {percent_of_port}', - # '$val: {base_unit_value}', + '{entry_size} @ {percent_pnl}% PnL', + '{percent_of_port}% of port = ${base_unit_value}', )), + fields={ - 'entry_count': 0, - 'percent_of_port': 0, - 'base_unit_value': 0, + 'entry_size': 0, + 'percent_pnl': 0, + 'percent_of_port': 2, + 'base_unit_value': '1k', }, ) pp_label.render() from PyQt5.QtCore import QPointF + from . import _lines # order line endpoint anchor - def bottom_marker_right() -> QPointF: + def align_to_pp_label() -> QPointF: + # pp = _lines._pp_label.txt + # scene_rect = pp.mapToScene(pp.boundingRect()).boundingRect() + # br = scene_rect.bottomRight() return QPointF( - marker_right_points(chart)[0] - pp_label.w, + + marker_right_points(chart)[2] - pp_label.w , view.height() - pp_label.h, + # br.x() - pp_label.w, + # br.y(), ) # TODO: position on botto if l1/book is on top side - pp_label.scene_anchor = bottom_marker_right + pp_label.scene_anchor = align_to_pp_label pp_label.hide() mode = OrderMode( @@ -483,9 +528,18 @@ async def run_order_mode( else: # to be safe mode._size = 1.0 - # update any exising positions + # update any exising position for sym, msg in positions.items(): - mode.on_position_update(msg) + + our_sym = mode.chart._lc._symbol.key + if sym.lower() in our_sym: + + mode._position.update(msg) + size = msg['size'] + price = msg['avg_price'] + mode.on_position_update(size, price) + pp_label.fields['entry_size'] = size + pp_label.render() def get_index(time: float): @@ -522,7 +576,19 @@ async def run_order_mode( 'position', ): # show line label once order is live - mode.on_position_update(msg) + + sym = mode.chart._lc._symbol + if msg['symbol'].lower() in sym.key: + + mode._position.update(msg) + size = msg['size'] + price = msg['avg_price'] + mode.on_position_update(size, price) + pp_label.fields['entry_size'] = size + pp_label.render() + + # short circuit to next msg to avoid + # uncessary msg content lookups continue resp = msg['resp'] From afcb323c49ead88ba9b205207ec9eb5aa7c063c2 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 19 Jul 2021 08:40:51 -0400 Subject: [PATCH 010/178] Use `QGraphicsPathItem` for marker, add line hide method --- piker/ui/_lines.py | 79 ++++++++++++++++++++++++++-------------------- 1 file changed, 45 insertions(+), 34 deletions(-) diff --git a/piker/ui/_lines.py b/piker/ui/_lines.py index d354e6b9..e4c2aecc 100644 --- a/piker/ui/_lines.py +++ b/piker/ui/_lines.py @@ -34,7 +34,7 @@ from ._anchors import ( vbr_left, right_axis, update_pp_nav, - path_topright, + gpath_pin, ) from ._label import Label from ._style import hcolor, _font @@ -356,7 +356,8 @@ class LevelLine(pg.InfiniteLine): ) # TODO: make this label update part of a scene-aware-marker # composed annotation - self._marker.label.update() + if hasattr(self._marker, 'label'): + self._marker.label.update() elif not self.use_marker_margin: # basically means **don't** shorten the line with normally @@ -378,6 +379,13 @@ class LevelLine(pg.InfiniteLine): super().hide() if self._marker: self._marker.hide() + self._marker.label.hide() + + def hide(self) -> None: + super().show() + if self._marker: + self._marker.show() + self._marker.label.show() def scene_right_xy(self) -> QPointF: return self.getViewBox().mapFromView( @@ -499,7 +507,6 @@ def level_line( hl_on_hover: bool = True, # label fields and options - digits: int = 1, always_show_labels: bool = False, add_label: bool = True, orient_v: str = 'bottom', @@ -578,7 +585,7 @@ def order_line( action: str, # buy or sell size: Optional[int] = 1, - size_digits: int = 0, + size_digits: int = 1, show_markers: bool = False, submit_price: float = None, exec_type: str = 'dark', @@ -615,32 +622,35 @@ def order_line( 'alert': ('v', alert_size), }[action] - # this fixes it the artifact issue! .. of course, bouding rect stuff + # this fixes it the artifact issue! .. of course, bounding rect stuff line._maxMarkerSize = marker_size # use ``QPathGraphicsItem``s to draw markers in scene coords # instead of the old way that was doing the same but by # resetting the graphics item transform intermittently - # XXX: this is our new approach but seems slower? - # path = line.add_marker(mk_marker(marker_style, marker_size)) - # assert line._marker == path - - assert not line.markers - # the old way which is still somehow faster? path = mk_marker( marker_style, # the "position" here is now ignored since we modified # internals to pin markers to the right end of the line marker_size, - use_qgpath=False, + + # uncommment for the old transform / .pain() marker method + # use_qgpath=False, ) - # manually append for later ``InfiniteLine.paint()`` drawing - # XXX: this was manually tested as faster then using the - # QGraphicsItem around a painter path.. probably needs further - # testing to figure out why tf that's true. - line.markers.append((path, 0, marker_size)) + + # XXX: this is our new approach but seems slower? + path = line.add_marker(mk_marker(marker_style, marker_size)) + line._marker = path + + assert not line.markers + + # # manually append for later ``InfiniteLine.paint()`` drawing + # # XXX: this was manually tested as faster then using the + # # QGraphicsItem around a painter path.. probably needs further + # # testing to figure out why tf that's true. + # line.markers.append((path, 0, marker_size)) orient_v = 'top' if action == 'sell' else 'bottom' @@ -669,34 +679,34 @@ def order_line( llabel.render() llabel.show() + path.label = llabel + else: rlabel = Label( view=line.getViewBox(), - # display the order pos size - fmt_str=('{size:.{size_digits}f} '), + # display the order pos size, which is some multiple + # of the user defined base unit size + fmt_str=('x{size:.0f}'), + # fmt_str=('{size:.{size_digits}f}'), # old color=line.color, ) + path.label = rlabel - # set anchor callback - # right side label by default - rlabel.set_x_anchor_func( - right_axis( - chart, - rlabel, - side='left', # side of axis - offset=4*marker_size, - avoid_book=True, - ) + rlabel.scene_anchor = partial( + gpath_pin, + location_description='right-of-path-centered', + gpath=path, + label=rlabel, ) line._labels.append(rlabel) rlabel.fields = { 'size': size, - 'size_digits': size_digits, + # 'size_digits': size_digits, } rlabel.orient_v = orient_v @@ -710,18 +720,18 @@ def order_line( def position_line( + chart, size: float, - level: float, orient_v: str = 'bottom', ) -> LevelLine: - """Convenience routine to add a line graphic representing an order + '''Convenience routine to add a line graphic representing an order execution submitted to the EMS via the chart's "order mode". - """ + ''' hcolor = 'default_light' line = level_line( @@ -767,10 +777,11 @@ def position_line( color=hcolor, update_on_range_change=False, ) + arrow_path.label = marker_label marker_label.scene_anchor = partial( - path_topright, + gpath_pin, gpath=arrow_path, label=marker_label, ) From 74d6dd59578e83dc7a2627218a0c6071b777bae0 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 21 Jul 2021 19:42:15 -0400 Subject: [PATCH 011/178] Move position tracking to new module It was becoming too much with all the labels and markers and lines.. Might as well package it all together instead of cramming it in the order mode loop, chief. The techincal summary, - move `_lines.position_line()` -> `PositionInfo.position_line()`. - slap a `.pp` on the order mode instance which *is* a `PositionInfo` - drop the position info info label for now (let's see what users want eventually but for now let's keep it super minimal). - add a `LevelMarker` type to replace the old `LevelLine` internal marker system (includes ability to change the style and level on the fly). - change `_annotate.mk_marker()` -> `mk_maker_path()` and expect caller to wrap in a `QGraphicsPathItem` if needed. --- piker/ui/_annotate.py | 14 +- piker/ui/_position.py | 549 +++++++++++++++++++++++++++++++++++++++++ piker/ui/order_mode.py | 171 ++++--------- 3 files changed, 606 insertions(+), 128 deletions(-) create mode 100644 piker/ui/_position.py diff --git a/piker/ui/_annotate.py b/piker/ui/_annotate.py index 36765026..372aae78 100644 --- a/piker/ui/_annotate.py +++ b/piker/ui/_annotate.py @@ -25,11 +25,11 @@ from pyqtgraph import Point, functions as fn, Color import numpy as np -def mk_marker( +def mk_marker_path( style, - size: float = 20.0, - use_qgpath: bool = True, + # size: float = 20.0, + # use_path_type: type = QGraphicsPathItem ) -> QGraphicsPathItem: """Add a marker to be displayed on the line wrapped in a ``QGraphicsPathItem`` @@ -39,7 +39,7 @@ def mk_marker( style String indicating the style of marker to add: ``'<|'``, ``'|>'``, ``'>|'``, ``'|<'``, ``'<|>'``, ``'>|<'``, ``'^'``, ``'v'``, ``'o'`` - size Size of the marker in pixels. Default is 10.0. + size Size of the marker in pixels. """ path = QtGui.QPainterPath() @@ -83,9 +83,9 @@ def mk_marker( # self._maxMarkerSize = max([m[2] / 2. for m in self.markers]) - if use_qgpath: - path = QGraphicsPathItem(path) - path.scale(size, size) + # if use_path_type: + # path = use_path_type(path) + # path.scale(size, size) return path diff --git a/piker/ui/_position.py b/piker/ui/_position.py new file mode 100644 index 00000000..508985b7 --- /dev/null +++ b/piker/ui/_position.py @@ -0,0 +1,549 @@ +# piker: trading gear for hackers +# Copyright (C) Tyler Goodlet (in stewardship for piker0) + +# 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 . + +""" +Position info and display + +""" +from typing import Optional, Dict, Any, Callable +from functools import partial +from math import floor + +from pyqtgraph import Point, functions as fn +from pydantic import BaseModel +from PyQt5 import QtGui, QtWidgets +from PyQt5.QtCore import QPointF +from PyQt5.QtGui import QGraphicsPathItem + +from ._annotate import mk_marker_path +from ._anchors import ( + marker_right_points, + gpath_pin, + # keep_marker_in_view, +) +from ._label import Label +from ._lines import LevelLine, level_line +from ._style import _font +from ..data._source import Symbol + + +class Position(BaseModel): + '''Basic pp representation with attached fills history. + + ''' + symbol: Symbol + size: float + avg_price: float # TODO: contextual pricing + fills: Dict[str, Any] = {} + + +class LevelMarker(QGraphicsPathItem): + '''An arrow marker path graphich which redraws itself + to the specified view coordinate level on each paint cycle. + + ''' + def __init__( + self, + chart: 'ChartPlotWidget', # noqa + style: str, + get_level: Callable[..., float], + size: float = 20, + keep_in_view: bool = True, + + ) -> None: + + self._style = None + self.size = size + + # get polygon and scale + super().__init__() + + # interally generates and scales path + self.style = style + # path = mk_marker_path(style) + + # self.scale(size, size) + + self.chart = chart + # chart.getViewBox().scene().addItem(self) + + self.get_level = get_level + self.scene_x = lambda: marker_right_points(chart)[1] + self.level: float = 0 + self.keep_in_view = keep_in_view + + # get the path for the opaque path **without** weird + # surrounding margin + self.path_br = self.mapToScene( + self.path() + ).boundingRect() + + + @property + def style(self) -> str: + return self._style + + @style.setter + def style(self, value: str) -> None: + if self._style != value: + polygon = mk_marker_path(value) + self.setPath(polygon) + self._style = value + self.scale(self.size, self.size) + + def delete(self) -> None: + self.scene().removeItem(self) + + @property + def h(self) -> float: + return self.path_br.height() + + @property + def w(self) -> float: + return self.path_br.width() + + def position_in_view( + self, + # level: float, + + ) -> None: + '''Show a pp off-screen indicator for a level label. + + This is like in fps games where you have a gps "nav" indicator + but your teammate is outside the range of view, except in 2D, on + the y-dimension. + + ''' + level = self.get_level() + + view = self.chart.getViewBox() + vr = view.state['viewRange'] + ymn, ymx = vr[1] + + # _, marker_right, _ = marker_right_points(line._chart) + x = self.scene_x() + + if level > ymx: # pin to top of view + self.setPos( + QPointF( + x, + self.h/3, + ) + ) + + elif level < ymn: # pin to bottom of view + + self.setPos( + QPointF( + x, + view.height() - 4/3*self.h, + ) + ) + + else: + # # pp line is viewable so show marker normally + # self.update() + self.setPos( + x, + self.chart.view.mapFromView( + QPointF(0, self.get_level()) + ).y() + ) + + # marker = line._marker + if getattr(self, 'label', None): + label = self.label + + # re-anchor label (i.e. trigger call of ``arrow_tr()`` from above + label.update() + + def paint( + self, + + p: QtGui.QPainter, + opt: QtWidgets.QStyleOptionGraphicsItem, + w: QtWidgets.QWidget + + ) -> None: + '''Core paint which we override to always update + our marker position in scene coordinates from a + view cooridnate "level". + + ''' + if self.keep_in_view: + self.position_in_view() + + else: + + # just place at desired level even if not in view + self.setPos( + self.scene_x(), + self.mapToScene(QPointF(0, self.get_level())).y() + ) + + return super().paint(p, opt, w) + + +class PositionInfo: + + # inputs + chart: 'ChartPlotWidget' # noqa + info: dict + + # allocated + pp_label: Label + size_label: Label + info_label: Label + line: Optional[LevelLine] = None + + _color: str = 'default_light' + + def __init__( + self, + chart: 'ChartPlotWidget', # noqa + + ) -> None: + + # from . import _lines + + self.chart = chart + self.info = {} + self.pp_label = None + + view = chart.getViewBox() + + # create placeholder 'up' level arrow + self._level_marker = None + self._level_marker = self.level_marker(size=1) + + # literally 'pp' label that's always in view + self.pp_label = pp_label = Label( + view=view, + fmt_str='pp', + color=self._color, + update_on_range_change=False, + ) + + self._level_marker.label = pp_label + + pp_label.scene_anchor = partial( + gpath_pin, + gpath=self._level_marker, + label=pp_label, + ) + pp_label.render() + pp_label.show() + + self.size_label = size_label = Label( + + view=view, + color=self._color, + + # this is "static" label + # update_on_range_change=False, + fmt_str='\n'.join(( + '{entry_size} x', + )), + + fields={ + 'entry_size': 0, + }, + ) + size_label.render() + # size_label.scene_anchor = self.align_to_marker + size_label.scene_anchor = partial( + gpath_pin, + location_description='left-of-path-centered', + gpath=self._level_marker, + label=size_label, + ) + size_label.hide() + + # self.info_label = info_label = Label( + + # view=view, + # color=self._color, + + # # this is "static" label + # # update_on_range_change=False, + + # fmt_str='\n'.join(( + # # '{entry_size}x ', + # '{percent_pnl} % PnL', + # # '{percent_of_port}% of port', + # '${base_unit_value}', + # )), + + # fields={ + # # 'entry_size': 0, + # 'percent_pnl': 0, + # 'percent_of_port': 2, + # 'base_unit_value': '1k', + # }, + # ) + # info_label.scene_anchor = lambda: self.size_label.txt.pos() + # + QPointF(0, self.size_label.h) + # info_label.render() + # info_label.hide() + + def level(self) -> float: + if self.line: + return self.line.value() + else: + return 0 + + def show(self) -> None: + self.pp_label.show() + self.size_label.show() + # self.info_label.show() + if self.line: + self.line.show() + + def hide(self) -> None: + # self.pp_label.hide() + self.size_label.hide() + # self.info_label.hide() + + # if self.line: + # self.line.hide() + + def level_marker( + self, + size: float, + + ) -> QGraphicsPathItem: + + if self._level_marker: + self._level_marker.delete() + + # arrow marker + # scale marker size with dpi-aware font size + font_size = _font.font.pixelSize() + + # scale marker size with dpi-aware font size + arrow_size = floor(1.375 * font_size) + + if size > 0: + style = '|<' + direction = 'up' + + elif size < 0: + style = '>|' + direction = 'down' + + arrow = LevelMarker( + chart=self.chart, + style=style, + get_level=self.level, + size=arrow_size, + ) + # _, marker_right, _ = marker_right_points(self.chart) + # arrow.scene_x = marker_right + + # monkey-cache height for sizing on pp nav-hub + # arrow._height = path_br.height() + # arrow._width = path_br.width() + arrow._direction = direction + + self.chart.getViewBox().scene().addItem(arrow) + arrow.show() + + # arrow.label = self.pp_label + + # inside ``LevelLine.pain()`` this is updates... + # we need a better way to have the label updated as frequenty + # as every paint call? Maybe use a better slot then the range + # change? + # self._level_marker.label = self.pp_label + + return arrow + + def position_line( + self, + + size: float, + level: float, + + orient_v: str = 'bottom', + + ) -> LevelLine: + '''Convenience routine to add a line graphic representing an order + execution submitted to the EMS via the chart's "order mode". + + ''' + self.line = line = level_line( + self.chart, + level, + color=self._color, + add_label=False, + hl_on_hover=False, + movable=False, + hide_xhair_on_hover=False, + use_marker_margin=True, + only_show_markers_on_hover=False, + always_show_labels=True, + ) + + if size > 0: + style = '|<' + elif size < 0: + style = '>|' + + self._level_marker.style = style + + # last_direction = self._level_marker._direction + # if ( + # size < 0 and last_direction == 'up' + # ): + # self._level_marker = self.level_marker(size) + marker = self._level_marker + + # add path to scene + # line.getViewBox().scene().addItem(marker) + + # set marker color to same as line + marker.setPen(line.currentPen) + marker.setBrush(fn.mkBrush(line.currentPen.color())) + marker.level = level + marker.update() + marker.show() + + # hide position marker when out of view (for now) + vb = line.getViewBox() + vb.sigRangeChanged.connect(marker.position_in_view) + + line._labels.append(self.pp_label) + + # XXX: uses new marker drawing approach + # line.add_marker(self._level_marker) + line.set_level(level) + + # sanity check + line.update_labels({'level': level}) + + # vb.sigRangeChanged.connect( + # partial(keep_marker_in_view, chartview=vb, line=line) + # ) + + return line + + # order line endpoint anchor + def align_to_marker(self) -> QPointF: + + pp_line = self.line + if pp_line: + + line_ep = pp_line.scene_endpoint() + # print(line_ep) + + y_level_scene = line_ep.y() + # pp_y = pp_label.txt.pos().y() + + # if y_level_scene > pp_y: + # y_level_scene = pp_y + + # elif y_level_scene + mkr_pos = self._level_marker.pos() + + left_of_mkr = QPointF( + # line_ep.x() - self.size_label.w, + mkr_pos.x() - self.size_label.w, + mkr_pos.y(), + # self._level_marker + # max(0, y_level_scene), + # min( + # pp_label.txt.pos().y() + # ), + ) + return left_of_mkr + + # return QPointF( + + # marker_right_points(chart)[2] - pp_label.w , + # view.height() - pp_label.h, + # # br.x() - pp_label.w, + # # br.y(), + # ) + + else: + # pp = _lines._pp_label.txt + # scene_rect = pp.mapToScene(pp.boundingRect()).boundingRect() + # br = scene_rect.bottomRight() + + return QPointF(0, 0) + + def update_line( + self, + + price: float, + size: float, + + ) -> None: + '''Update personal position level line. + + + ''' + # do line update + line = self.line + + if line is None and size: + + # create and show a pp line + line = self.line = self.position_line( + level=price, + size=size, + ) + line.show() + + elif line: + + if size != 0.0: + line.set_level(price) + self._level_marker.lelvel = price + self._level_marker.update() + line.update_labels({'size': size}) + line.show() + + else: + # remove pp line from view + line.delete() + self.line = None + + def update( + self, + + avg_price: float, + size: float, + + ) -> None: + '''Update graphics and data from average price and size. + + ''' + self.update_line(avg_price, size) + + self._level_marker.level = avg_price + self._level_marker.update() # trigger paint + + # info updates + self.info['avg_price'] = avg_price + self.info['size'] = size + + # label updates + self.size_label.fields['entry_size'] = size + self.size_label.render() + + # self.info_label.fields['size'] = size + # self.info_label.render() diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index b0fb5a79..cd33a00f 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -28,29 +28,18 @@ from pydantic import BaseModel import tractor import trio -from ._anchors import marker_right_points from ..clearing._client import open_ems, OrderBook from ..data._source import Symbol from ..log import get_logger from ._editors import LineEditor, ArrowEditor -from ._label import Label -from ._lines import LevelLine, position_line +from ._lines import LevelLine +from ._position import PositionInfo from ._window import MultiStatus, main_window log = get_logger(__name__) -class Position(BaseModel): - '''Basic pp representation with attached fills history. - - ''' - symbol: Symbol - size: float - avg_price: float # TODO: contextual pricing - fills: Dict[str, Any] = {} - - class OrderDialog(BaseModel): '''Trade dialogue meta-data describing the lifetime of an order submission to ``emsd`` from a chart. @@ -94,7 +83,7 @@ class OrderMode: status_bar: MultiStatus # pp status info - label: Label + # label: Label name: str = 'order' @@ -107,41 +96,41 @@ class OrderMode: _exec_mode: str = 'dark' _size: float = 100.0 _position: Dict[str, Any] = field(default_factory=dict) - _position_line: dict = None + # _position_line: dict = None dialogs: dict[str, OrderDialog] = field(default_factory=dict) - def on_position_update( - self, + # def on_position_update( + # self, - size: float, - price: float, + # size: float, + # price: float, - ) -> None: + # ) -> None: - line = self._position_line + # line = self._position_line - if line is None and size: + # if line is None and size: - # create and show a pp line - line = self._position_line = position_line( - self.chart, - level=price, - size=size, - ) - line.show() + # # create and show a pp line + # line = self._position_line = position_line( + # self.chart, + # level=price, + # size=size, + # ) + # line.show() - elif line: + # elif line: - if size != 0.0: - line.set_level(price) - line.update_labels({'size': size}) - line.show() + # if size != 0.0: + # line.set_level(price) + # line.update_labels({'size': size}) + # line.show() - else: - # remove pp line from view - line.delete() - self._position_line = None + # else: + # # remove pp line from view + # line.delete() + # self._position_line = None def uuid(self) -> str: return str(uuid.uuid4()) @@ -394,35 +383,6 @@ class OrderMode: ) -class PositionInfo: - - line: LevelLine - pp_label: Label - size_label: Label - info_label: Label - info: dict - - def update( - self, - avg_price, - size, - - ) -> None: - - self.info['avg_price'] = avg_price - self.size_label.fields['size'] = size - self.info_label.fields['size'] = size - - -def position_info( - - price: float, - size: float - -) -> PositionInfo: - pass - - async def run_order_mode( chart: 'ChartPlotWidget', # noqa @@ -463,47 +423,7 @@ async def run_order_mode( log.info("Opening order mode") - pp_label = Label( - view=view, - color='default_light', - - # this is "static" label - # update_on_range_change=False, - - fmt_str='\n'.join(( - '{entry_size} @ {percent_pnl}% PnL', - '{percent_of_port}% of port = ${base_unit_value}', - )), - - fields={ - 'entry_size': 0, - 'percent_pnl': 0, - 'percent_of_port': 2, - 'base_unit_value': '1k', - }, - ) - pp_label.render() - - from PyQt5.QtCore import QPointF - from . import _lines - - # order line endpoint anchor - def align_to_pp_label() -> QPointF: - # pp = _lines._pp_label.txt - # scene_rect = pp.mapToScene(pp.boundingRect()).boundingRect() - # br = scene_rect.bottomRight() - - return QPointF( - - marker_right_points(chart)[2] - pp_label.w , - view.height() - pp_label.h, - # br.x() - pp_label.w, - # br.y(), - ) - - # TODO: position on botto if l1/book is on top side - pp_label.scene_anchor = align_to_pp_label - pp_label.hide() + pp = PositionInfo(chart) mode = OrderMode( chart, @@ -511,8 +431,8 @@ async def run_order_mode( lines, arrows, status_bar, - label=pp_label, ) + mode.pp = pp view.mode = mode @@ -534,12 +454,16 @@ async def run_order_mode( our_sym = mode.chart._lc._symbol.key if sym.lower() in our_sym: - mode._position.update(msg) - size = msg['size'] - price = msg['avg_price'] - mode.on_position_update(size, price) - pp_label.fields['entry_size'] = size - pp_label.render() + pp.update( + avg_price=msg['avg_price'], + size=msg['size'], + ) + + # mode._position.update(msg) + # size = msg['size'] + # price = msg['avg_price'] + # pp_label.fields['entry_size'] = size + # pp_label.render() def get_index(time: float): @@ -580,12 +504,17 @@ async def run_order_mode( sym = mode.chart._lc._symbol if msg['symbol'].lower() in sym.key: - mode._position.update(msg) - size = msg['size'] - price = msg['avg_price'] - mode.on_position_update(size, price) - pp_label.fields['entry_size'] = size - pp_label.render() + pp.update( + avg_price=msg['avg_price'], + size=msg['size'], + ) + + # mode._position.update(msg) + # size = msg['size'] + # price = msg['avg_price'] + # pp.update(size, price) + # pp_label.fields['entry_size'] = size + # pp_label.render() # short circuit to next msg to avoid # uncessary msg content lookups From 20a8045127e461f6c048a12ec8eaf1fc4dbdf9b0 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 21 Jul 2021 19:59:29 -0400 Subject: [PATCH 012/178] Add a left-side-of-marker orientation --- piker/ui/_anchors.py | 52 +++++--------------------------------------- 1 file changed, 5 insertions(+), 47 deletions(-) diff --git a/piker/ui/_anchors.py b/piker/ui/_anchors.py index b04ea54a..13add46c 100644 --- a/piker/ui/_anchors.py +++ b/piker/ui/_anchors.py @@ -39,6 +39,8 @@ def marker_right_points( axis respectively. ''' + # TODO: compute some sensible maximum value here + # and use a humanized scheme to limit to that length. l1_len = chart._max_l1_line_len ryaxis = chart.getAxis('right') @@ -111,53 +113,6 @@ def right_axis( return on_axis -def update_pp_nav( - - chartview: 'ChartView', # noqa - line: 'LevelLine', # noqa - -) -> None: - '''Show a pp off-screen indicator for a level label. - - This is like in fps games where you have a gps "nav" indicator - but your teammate is outside the range of view, except in 2D, on - the y-dimension. - - ''' - vr = chartview.state['viewRange'] - ymn, ymx = vr[1] - level = line.value() - - marker = line._marker - label = marker.label - - _, marker_right, _ = marker_right_points(line._chart) - - if level > ymx: # pin to top of view - marker.setPos( - QPointF( - marker_right, - marker._height/3, - ) - ) - - elif level < ymn: # pin to bottom of view - - marker.setPos( - QPointF( - marker_right, - chartview.height() - 4/3*marker._height, - ) - ) - - else: - # pp line is viewable so show marker normally - marker.update() - - # re-anchor label (i.e. trigger call of ``arrow_tr()`` from above - label.update() - - def gpath_pin( gpath: QGraphicsPathItem, @@ -176,6 +131,9 @@ def gpath_pin( if location_description == 'right-of-path-centered': return path_br.topRight() - QPointF(0, label.h / 3) + if location_description == 'left-of-path-centered': + return path_br.topLeft() - QPointF(label.w, label.h / 6) + elif location_description == 'below-path-left-aligned': return path_br.bottomLeft() - QPointF(0, label.h / 6) From 71eef1b7fd617c31362422d1947a59d579a555cb Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 21 Jul 2021 20:00:11 -0400 Subject: [PATCH 013/178] Add `.view` property, throttle to 50Hz by default --- piker/ui/_chart.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index f1ade0f4..0e967128 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -535,6 +535,10 @@ class ChartPlotWidget(pg.PlotWidget): # for when the splitter(s) are resized self._vb.sigResized.connect(self._set_yrange) + @property + def view(self) -> ChartView: + return self._vb + def focus(self) -> None: # self.setFocus() self._vb.setFocus() @@ -924,7 +928,7 @@ class ChartPlotWidget(pg.PlotWidget): self.scene().leaveEvent(ev) -_clear_throttle_rate: int = 60 # Hz +_clear_throttle_rate: int = 50 # Hz _book_throttle_rate: int = 16 # Hz From f06e05c9cb223acab98b6de500940d2d6c3ef5fa Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 21 Jul 2021 20:00:57 -0400 Subject: [PATCH 014/178] Switch mode to touch `.pp` --- piker/ui/_interaction.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 89cc9474..286783cf 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -172,6 +172,10 @@ async def handle_viewmode_inputs( }.intersection(pressed) if order_keys_pressed: + + # show the pp label + mode.pp.show() + if ( # 's' for "submit" to activate "live" order Qt.Key_S in pressed or @@ -204,6 +208,10 @@ async def handle_viewmode_inputs( f'mode: {prefix}{action}') else: # none active + + # hide pp label + mode.pp.hide() + # if none are pressed, remove "staged" level # line under cursor position view.mode.lines.unstage_line() @@ -231,9 +239,10 @@ class ChartView(ViewBox): mode_name: str = 'mode: view' def __init__( - self, + name: str, + parent: pg.PlotItem = None, **kwargs, @@ -250,7 +259,6 @@ class ChartView(ViewBox): self.select_box = SelectRect(self) self.addItem(self.select_box, ignoreBounds=True) - self.name = name self.mode = None self.order_mode: bool = False @@ -259,6 +267,7 @@ class ChartView(ViewBox): @asynccontextmanager async def open_async_input_handler( self, + ) -> 'ChartView': from . import _event From 565380368abaa8225ecb17fdcdf98a4f4d888a76 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 21 Jul 2021 20:02:09 -0400 Subject: [PATCH 015/178] Increase cursor debounce delay slightly? --- piker/ui/_cursor.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/piker/ui/_cursor.py b/piker/ui/_cursor.py index 8a74ffc7..7415005a 100644 --- a/piker/ui/_cursor.py +++ b/piker/ui/_cursor.py @@ -42,7 +42,7 @@ log = get_logger(__name__) # latency (in terms of perceived lag in cross hair) so really be sure # there's an improvement if you want to change it! _mouse_rate_limit = 60 # TODO; should we calc current screen refresh rate? -_debounce_delay = 1 / 2e3 +_debounce_delay = 1 / 1e3 _ch_label_opac = 1 @@ -52,12 +52,15 @@ class LineDot(pg.CurvePoint): def __init__( self, + curve: pg.PlotCurveItem, index: int, + plot: 'ChartPlotWidget', # type: ingore # noqa pos=None, size: int = 6, # in pxs color: str = 'default_light', + ) -> None: pg.CurvePoint.__init__( self, @@ -88,7 +91,9 @@ class LineDot(pg.CurvePoint): def event( self, + ev: QtCore.QEvent, + ) -> None: if not isinstance( ev, QtCore.QDynamicPropertyChangeEvent @@ -132,8 +137,8 @@ class ContentsLabel(pg.LabelItem): } def __init__( - self, + # chart: 'ChartPlotWidget', # noqa view: pg.ViewBox, @@ -167,8 +172,8 @@ class ContentsLabel(pg.LabelItem): self.anchor(itemPos=index, parentPos=index, offset=margins) def update_from_ohlc( - self, + name: str, index: int, array: np.ndarray, @@ -194,8 +199,8 @@ class ContentsLabel(pg.LabelItem): ) def update_from_value( - self, + name: str, index: int, array: np.ndarray, @@ -239,6 +244,7 @@ class ContentsLabels: if not (index >= 0 and index < chart._arrays['ohlc'][-1]['index']): # out of range + print('out of range?') continue array = chart._arrays[name] @@ -278,7 +284,9 @@ class ContentsLabels: class Cursor(pg.GraphicsObject): + '''Multi-plot cursor for use on a ``LinkedSplits`` chart (set). + ''' def __init__( self, From 444421bddf070e35a63a991c030492b4ca0f5ccc Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 21 Jul 2021 20:03:11 -0400 Subject: [PATCH 016/178] Make our default label opaque (since it's normally just text) --- piker/ui/_label.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/piker/ui/_label.py b/piker/ui/_label.py index ea1fb4b6..1e4af75e 100644 --- a/piker/ui/_label.py +++ b/piker/ui/_label.py @@ -56,10 +56,10 @@ class Label: view: pg.ViewBox, fmt_str: str, - color: str = 'bracket', + color: str = 'default_light', x_offset: float = 0, font_size: str = 'small', - opacity: float = 0.666, + opacity: float = 1, fields: dict = {}, update_on_range_change: bool = True, From d3457cd42349dc4e41ebc4538452650b73db4466 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 21 Jul 2021 20:06:14 -0400 Subject: [PATCH 017/178] Drop position-line factory from lines module, add endpoint getter --- piker/ui/_lines.py | 141 +++++++++++---------------------------------- 1 file changed, 33 insertions(+), 108 deletions(-) diff --git a/piker/ui/_lines.py b/piker/ui/_lines.py index e4c2aecc..f0c137b2 100644 --- a/piker/ui/_lines.py +++ b/piker/ui/_lines.py @@ -28,12 +28,11 @@ from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtCore import QPointF from PyQt5.QtGui import QGraphicsPathItem -from ._annotate import mk_marker, qgo_draw_markers +from ._annotate import mk_marker_path, qgo_draw_markers from ._anchors import ( marker_right_points, vbr_left, right_axis, - update_pp_nav, gpath_pin, ) from ._label import Label @@ -121,8 +120,6 @@ class LevelLine(pg.InfiniteLine): self._on_drag_end = lambda l: None self._y_incr_mult = 1 / chart._lc._symbol.tick_size - self._last_scene_y: float = 0 - self._right_end_sc: float = 0 def txt_offsets(self) -> Tuple[int, int]: @@ -351,11 +348,12 @@ class LevelLine(pg.InfiniteLine): # order lines.. not sure wtf is up with that. # for now we're just using it on the position line. elif self._marker: + + # TODO: make this label update part of a scene-aware-marker + # composed annotation self._marker.setPos( QPointF(marker_right, self.scene_y()) ) - # TODO: make this label update part of a scene-aware-marker - # composed annotation if hasattr(self._marker, 'label'): self._marker.label.update() @@ -379,21 +377,26 @@ class LevelLine(pg.InfiniteLine): super().hide() if self._marker: self._marker.hide() - self._marker.label.hide() + # self._marker.label.hide() - def hide(self) -> None: + def show(self) -> None: super().show() if self._marker: self._marker.show() - self._marker.label.show() - - def scene_right_xy(self) -> QPointF: - return self.getViewBox().mapFromView( - QPointF(0, self.value()) - ) + # self._marker.label.show() def scene_y(self) -> float: - return self.getViewBox().mapFromView(Point(0, self.value())).y() + return self.getViewBox().mapFromView( + Point(0, self.value()) + ).y() + + def scene_endpoint(self) -> QPointF: + + if not self._right_end_sc: + line_end, _, _ = marker_right_points(self._chart) + self._right_end_sc = line_end - 10 + + return QPointF(self._right_end_sc, self.scene_y()) def add_marker( self, @@ -414,7 +417,6 @@ class LevelLine(pg.InfiniteLine): path.setPos(QPointF(rsc, self.scene_y())) return path - # self.update() def hoverEvent(self, ev): """Mouse hover callback. @@ -630,18 +632,25 @@ def order_line( # resetting the graphics item transform intermittently # the old way which is still somehow faster? - path = mk_marker( - marker_style, - # the "position" here is now ignored since we modified - # internals to pin markers to the right end of the line - marker_size, + path = QGraphicsPathItem( + mk_marker_path( + marker_style, + # the "position" here is now ignored since we modified + # internals to pin markers to the right end of the line + # marker_size, - # uncommment for the old transform / .pain() marker method - # use_qgpath=False, + # uncommment for the old transform / .paint() marker method + # use_qgpath=False, + ) ) + path.scale(marker_size, marker_size) # XXX: this is our new approach but seems slower? - path = line.add_marker(mk_marker(marker_style, marker_size)) + path = line.add_marker(path) + + # XXX: old + # path = line.add_marker(mk_marker(marker_style, marker_size)) + line._marker = path assert not line.markers @@ -717,87 +726,3 @@ def order_line( line.update_labels({'level': level}) return line - - -def position_line( - - chart, - size: float, - level: float, - - orient_v: str = 'bottom', - -) -> LevelLine: - '''Convenience routine to add a line graphic representing an order - execution submitted to the EMS via the chart's "order mode". - - ''' - hcolor = 'default_light' - - line = level_line( - chart, - level, - color=hcolor, - add_label=False, - hl_on_hover=False, - movable=False, - hide_xhair_on_hover=False, - use_marker_margin=True, - only_show_markers_on_hover=False, - always_show_labels=True, - ) - # hide position marker when out of view (for now) - vb = line.getViewBox() - - # arrow marker - # scale marker size with dpi-aware font size - font_size = _font.font.pixelSize() - - # scale marker size with dpi-aware font size - arrow_size = floor(1.375 * font_size) - - if size > 0: - style = '|<' - elif size < 0: - style = '>|' - - arrow_path = mk_marker(style, size=arrow_size) - - path_br = arrow_path.mapToScene( - arrow_path.path() - ).boundingRect() - - # monkey-cache height for sizing on pp nav-hub - arrow_path._height = path_br.height() - arrow_path._width = path_br.width() - - marker_label = Label( - view=vb, - fmt_str='pp', - color=hcolor, - update_on_range_change=False, - ) - - arrow_path.label = marker_label - - marker_label.scene_anchor = partial( - gpath_pin, - gpath=arrow_path, - label=marker_label, - ) - - line._labels.append(marker_label) - - marker_label.render() - marker_label.show() - - # XXX: uses new marker drawing approach - line.add_marker(arrow_path) - line.set_level(level) - - # sanity check - line.update_labels({'level': level}) - - vb.sigRangeChanged.connect(partial(update_pp_nav, chartview=vb, line=line)) - - return line From 826c4408eae92944fb9d1ec412f10b43250fcb42 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 21 Jul 2021 20:09:36 -0400 Subject: [PATCH 018/178] Stop pulling lot size precision from symbol for now in the UI --- piker/ui/_editors.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py index 1bb9d7e2..a35a8d88 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -105,6 +105,7 @@ class LineEditor: # fields settings size: Optional[int] = None, + ) -> LevelLine: """Stage a line at the current chart's cursor position and return it. @@ -128,10 +129,14 @@ class LineEditor: line = order_line( chart, + # TODO: convert these values into human-readable form + # (i.e. with k, m, M, B) type embedded suffixes level=y, level_digits=symbol.digits(), + size=size, - size_digits=symbol.lot_digits(), + # TODO: we need truncation checks in the EMS for this? + # size_digits=min(symbol.lot_digits(), 3), # just for the stage line to avoid # flickering while moving the cursor @@ -204,7 +209,8 @@ class LineEditor: level_digits=sym.digits(), size=size, - size_digits=sym.lot_digits(), + # TODO: we need truncation checks in the EMS for this? + # size_digits=sym.lot_digits(), # LevelLine kwargs color=line.color, From 1abbd095ec345b01544ff435f8bdae9a576f7167 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 22 Jul 2021 11:23:04 -0400 Subject: [PATCH 019/178] Fix oustanding label bugs, make `.update()` accept a position msg --- piker/ui/_position.py | 203 +++++++++++++++++++----------------------- 1 file changed, 92 insertions(+), 111 deletions(-) diff --git a/piker/ui/_position.py b/piker/ui/_position.py index 508985b7..3ce7ffea 100644 --- a/piker/ui/_position.py +++ b/piker/ui/_position.py @@ -18,11 +18,11 @@ Position info and display """ -from typing import Optional, Dict, Any, Callable +from typing import Optional, Callable from functools import partial from math import floor -from pyqtgraph import Point, functions as fn +from pyqtgraph import functions as fn from pydantic import BaseModel from PyQt5 import QtGui, QtWidgets from PyQt5.QtCore import QPointF @@ -34,20 +34,28 @@ from ._anchors import ( gpath_pin, # keep_marker_in_view, ) +from ..clearing._messages import BrokerdPosition, Status +from ..data._source import Symbol from ._label import Label from ._lines import LevelLine, level_line from ._style import _font -from ..data._source import Symbol class Position(BaseModel): - '''Basic pp representation with attached fills history. + '''Basic pp (personal position) data representation with attached + fills history. + + This type should be IPC wire ready? ''' symbol: Symbol + + # last size and avg entry price size: float avg_price: float # TODO: contextual pricing - fills: Dict[str, Any] = {} + + # ordered record of known constituent trade messages + fills: list[Status] = [] class LevelMarker(QGraphicsPathItem): @@ -65,32 +73,22 @@ class LevelMarker(QGraphicsPathItem): ) -> None: - self._style = None - self.size = size - # get polygon and scale super().__init__() + self.scale(size, size) - # interally generates and scales path + # interally generates path + self._style = None self.style = style - # path = mk_marker_path(style) - - # self.scale(size, size) self.chart = chart - # chart.getViewBox().scene().addItem(self) self.get_level = get_level self.scene_x = lambda: marker_right_points(chart)[1] self.level: float = 0 self.keep_in_view = keep_in_view - # get the path for the opaque path **without** weird - # surrounding margin - self.path_br = self.mapToScene( - self.path() - ).boundingRect() - + assert self.path_br @property def style(self) -> str: @@ -102,7 +100,13 @@ class LevelMarker(QGraphicsPathItem): polygon = mk_marker_path(value) self.setPath(polygon) self._style = value - self.scale(self.size, self.size) + + # get the path for the opaque path **without** weird + # surrounding margin + self.path_br = self.mapToScene( + self.path() + ).boundingRect() + def delete(self) -> None: self.scene().removeItem(self) @@ -154,8 +158,7 @@ class LevelMarker(QGraphicsPathItem): ) else: - # # pp line is viewable so show marker normally - # self.update() + # pp line is viewable so show marker normally self.setPos( x, self.chart.view.mapFromView( @@ -186,9 +189,7 @@ class LevelMarker(QGraphicsPathItem): if self.keep_in_view: self.position_in_view() - else: - - # just place at desired level even if not in view + else: # just place at desired level even if not in view self.setPos( self.scene_x(), self.mapToScene(QPointF(0, self.get_level())).y() @@ -197,16 +198,18 @@ class LevelMarker(QGraphicsPathItem): return super().paint(p, opt, w) -class PositionInfo: +class PositionTracker: + '''Track and display a real-time position for a single symbol + on a chart. + ''' # inputs chart: 'ChartPlotWidget' # noqa - info: dict # allocated + info: Position pp_label: Label size_label: Label - info_label: Label line: Optional[LevelLine] = None _color: str = 'default_light' @@ -217,10 +220,13 @@ class PositionInfo: ) -> None: - # from . import _lines - self.chart = chart - self.info = {} + self.info = Position( + symbol=chart.linked.symbol, + size=0, + avg_price=0, + ) + self.pp_label = None view = chart.getViewBox() @@ -248,14 +254,13 @@ class PositionInfo: pp_label.show() self.size_label = size_label = Label( - view=view, color=self._color, # this is "static" label # update_on_range_change=False, fmt_str='\n'.join(( - '{entry_size} x', + 'x{entry_size}', )), fields={ @@ -264,22 +269,13 @@ class PositionInfo: ) size_label.render() # size_label.scene_anchor = self.align_to_marker - size_label.scene_anchor = partial( - gpath_pin, - location_description='left-of-path-centered', - gpath=self._level_marker, - label=size_label, + + size_label.scene_anchor = lambda: ( + self.pp_label.txt.pos() + QPointF(self.pp_label.w, 0) ) size_label.hide() - # self.info_label = info_label = Label( - - # view=view, - # color=self._color, - - # # this is "static" label - # # update_on_range_change=False, - + # TODO: if we want to show more position-y info? # fmt_str='\n'.join(( # # '{entry_size}x ', # '{percent_pnl} % PnL', @@ -294,10 +290,36 @@ class PositionInfo: # 'base_unit_value': '1k', # }, # ) - # info_label.scene_anchor = lambda: self.size_label.txt.pos() - # + QPointF(0, self.size_label.h) - # info_label.render() - # info_label.hide() + + def update( + self, + msg: BrokerdPosition, + + ) -> None: + '''Update graphics and data from average price and size. + + ''' + avg_price, size = msg['avg_price'], msg['size'] + # info updates + self.info.avg_price = avg_price + self.info.size = size + + self.update_line(avg_price, size) + + # label updates + self.size_label.fields['entry_size'] = size + self.size_label.render() + + if size == 0: + self.hide() + + else: + self._level_marker.level = avg_price + self._level_marker.update() # trigger paint + self.show() + + # self.pp_label.show() + # self._level_marker.show() def level(self) -> float: if self.line: @@ -306,19 +328,25 @@ class PositionInfo: return 0 def show(self) -> None: - self.pp_label.show() - self.size_label.show() - # self.info_label.show() - if self.line: + if self.info.size: self.line.show() + self._level_marker.show() + self.pp_label.show() + self.size_label.show() def hide(self) -> None: - # self.pp_label.hide() + self.pp_label.hide() + self._level_marker.hide() self.size_label.hide() - # self.info_label.hide() + if self.line: + self.line.hide() - # if self.line: - # self.line.hide() + def hide_info(self) -> None: + '''Hide details of position. + + ''' + # TODO: add remove status bar widgets here + self.size_label.hide() def level_marker( self, @@ -402,17 +430,8 @@ class PositionInfo: elif size < 0: style = '>|' - self._level_marker.style = style - - # last_direction = self._level_marker._direction - # if ( - # size < 0 and last_direction == 'up' - # ): - # self._level_marker = self.level_marker(size) marker = self._level_marker - - # add path to scene - # line.getViewBox().scene().addItem(marker) + marker.style = style # set marker color to same as line marker.setPen(line.currentPen) @@ -421,23 +440,12 @@ class PositionInfo: marker.update() marker.show() - # hide position marker when out of view (for now) + # show position marker on view "edge" when out of view vb = line.getViewBox() vb.sigRangeChanged.connect(marker.position_in_view) - line._labels.append(self.pp_label) - - # XXX: uses new marker drawing approach - # line.add_marker(self._level_marker) line.set_level(level) - # sanity check - line.update_labels({'level': level}) - - # vb.sigRangeChanged.connect( - # partial(keep_marker_in_view, chartview=vb, line=line) - # ) - return line # order line endpoint anchor @@ -446,10 +454,10 @@ class PositionInfo: pp_line = self.line if pp_line: - line_ep = pp_line.scene_endpoint() + # line_ep = pp_line.scene_endpoint() # print(line_ep) - y_level_scene = line_ep.y() + # y_level_scene = line_ep.y() # pp_y = pp_label.txt.pos().y() # if y_level_scene > pp_y: @@ -494,7 +502,6 @@ class PositionInfo: ) -> None: '''Update personal position level line. - ''' # do line update line = self.line @@ -512,38 +519,12 @@ class PositionInfo: if size != 0.0: line.set_level(price) - self._level_marker.lelvel = price + self._level_marker.level = price self._level_marker.update() - line.update_labels({'size': size}) + # line.update_labels({'size': size}) line.show() else: # remove pp line from view line.delete() self.line = None - - def update( - self, - - avg_price: float, - size: float, - - ) -> None: - '''Update graphics and data from average price and size. - - ''' - self.update_line(avg_price, size) - - self._level_marker.level = avg_price - self._level_marker.update() # trigger paint - - # info updates - self.info['avg_price'] = avg_price - self.info['size'] = size - - # label updates - self.size_label.fields['entry_size'] = size - self.size_label.render() - - # self.info_label.fields['size'] = size - # self.info_label.render() From 1a5770c127c3cf39861031e9d259eee635cadcad Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 22 Jul 2021 11:23:41 -0400 Subject: [PATCH 020/178] Only hide position (extra) info on order mode exit --- piker/ui/_interaction.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 286783cf..4f4b00b6 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -114,7 +114,7 @@ async def handle_viewmode_inputs( Qt.Key_Space, } ): - view._chart._lc.godwidget.search.focus() + view._chart.linked.godwidget.search.focus() # esc and ctrl-c if key == Qt.Key_Escape or (ctrl and key == Qt.Key_C): @@ -210,7 +210,7 @@ async def handle_viewmode_inputs( else: # none active # hide pp label - mode.pp.hide() + mode.pp.hide_info() # if none are pressed, remove "staged" level # line under cursor position From 3b0b7475c859b470c9b6e8e16d4eded1627bfdc9 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 22 Jul 2021 11:24:25 -0400 Subject: [PATCH 021/178] Fixup commented view locate call --- piker/ui/_anchors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/piker/ui/_anchors.py b/piker/ui/_anchors.py index 13add46c..d8fdb4c4 100644 --- a/piker/ui/_anchors.py +++ b/piker/ui/_anchors.py @@ -126,7 +126,7 @@ def gpath_pin( # get actual arrow graphics path path_br = gpath.mapToScene(gpath.path()).boundingRect() - # vb.locate(arrow_path) #, children=True) + # label.vb.locate(label.txt) #, children=True) if location_description == 'right-of-path-centered': return path_br.topRight() - QPointF(0, label.h / 3) From c8b14e9445081a402f2b3043cac04a3faa0ecc0f Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 22 Jul 2021 11:39:47 -0400 Subject: [PATCH 022/178] Pass position msg to tracker, append fill msgs --- piker/ui/order_mode.py | 38 +++++++++++--------------------------- 1 file changed, 11 insertions(+), 27 deletions(-) diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index cd33a00f..7b9eb7fe 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -33,7 +33,7 @@ from ..data._source import Symbol from ..log import get_logger from ._editors import LineEditor, ArrowEditor from ._lines import LevelLine -from ._position import PositionInfo +from ._position import PositionTracker from ._window import MultiStatus, main_window @@ -259,8 +259,7 @@ class OrderMode: chart = cursor.active_plot y = cursor._datum_xy[1] - symbol = self.chart._lc._symbol - + symbol = self.chart.linked.symbol action = self._action # TODO: update the line once an ack event comes back @@ -423,7 +422,7 @@ async def run_order_mode( log.info("Opening order mode") - pp = PositionInfo(chart) + pp = PositionTracker(chart) mode = OrderMode( chart, @@ -432,6 +431,8 @@ async def run_order_mode( arrows, status_bar, ) + + # so that view handlers can access it mode.pp = pp view.mode = mode @@ -451,19 +452,10 @@ async def run_order_mode( # update any exising position for sym, msg in positions.items(): - our_sym = mode.chart._lc._symbol.key + our_sym = mode.chart.linked._symbol.key if sym.lower() in our_sym: - pp.update( - avg_price=msg['avg_price'], - size=msg['size'], - ) - - # mode._position.update(msg) - # size = msg['size'] - # price = msg['avg_price'] - # pp_label.fields['entry_size'] = size - # pp_label.render() + pp.update(msg) def get_index(time: float): @@ -501,20 +493,10 @@ async def run_order_mode( ): # show line label once order is live - sym = mode.chart._lc._symbol + sym = mode.chart.linked.symbol if msg['symbol'].lower() in sym.key: - pp.update( - avg_price=msg['avg_price'], - size=msg['size'], - ) - - # mode._position.update(msg) - # size = msg['size'] - # price = msg['avg_price'] - # pp.update(size, price) - # pp_label.fields['entry_size'] = size - # pp_label.render() + pp.update(msg) # short circuit to next msg to avoid # uncessary msg content lookups @@ -594,3 +576,5 @@ async def run_order_mode( # TODO: put the actual exchange timestamp arrow_index=get_index(details['broker_time']), ) + + pp.info.fills.append(msg) From 7367ed5464f1f8d2351b8d0f3929702e0e75cef9 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 22 Jul 2021 11:42:12 -0400 Subject: [PATCH 023/178] Drop all `ChartPlotWidget._lc` remap to `.linked --- piker/data/_source.py | 2 +- piker/ui/_chart.py | 14 ++++++-------- piker/ui/_editors.py | 4 ++-- piker/ui/_label.py | 3 --- piker/ui/_lines.py | 2 +- 5 files changed, 10 insertions(+), 15 deletions(-) diff --git a/piker/data/_source.py b/piker/data/_source.py index 23524426..bcfc6025 100644 --- a/piker/data/_source.py +++ b/piker/data/_source.py @@ -1,5 +1,5 @@ # piker: trading gear for hackers -# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0) +# Copyright (C) 2018-present Tyler Goodlet (in stewardship for piker0) # 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 diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 0e967128..d23e93b8 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -287,7 +287,6 @@ class LinkedSplits(QtWidgets.QWidget): self.layout.setContentsMargins(0, 0, 0, 0) self.layout.addWidget(self.splitter) - # state tracker? self._symbol: Symbol = None @property @@ -491,7 +490,6 @@ class ChartPlotWidget(pg.PlotWidget): **kwargs ) self.name = name - self._lc = linkedsplits self.linked = linkedsplits # scene-local placeholder for book graphics @@ -737,7 +735,7 @@ class ChartPlotWidget(pg.PlotWidget): # if the sticky is for our symbol # use the tick size precision for display - sym = self._lc.symbol + sym = self.linked.symbol if name == sym.key: digits = sym.digits() else: @@ -987,7 +985,7 @@ async def chart_from_quotes( last, volume = ohlcv.array[-1][['close', 'volume']] - symbol = chart._lc.symbol + symbol = chart.linked.symbol l1 = L1Labels( chart, @@ -1005,7 +1003,7 @@ async def chart_from_quotes( # levels this might be dark volume we need to # present differently? - tick_size = chart._lc.symbol.tick_size + tick_size = chart.linked.symbol.tick_size tick_margin = 2 * tick_size last_ask = last_bid = last_clear = time.time() @@ -1014,7 +1012,7 @@ async def chart_from_quotes( async for quotes in stream: # chart isn't actively shown so just skip render cycle - if chart._lc.isHidden(): + if chart.linked.isHidden(): continue for sym, quote in quotes.items(): @@ -1304,7 +1302,7 @@ async def run_fsp( value = array[fsp_func_name][-1] last_val_sticky.update_from_data(-1, value) - chart._lc.focus() + chart.linked.focus() # works also for overlays in which case data is looked up from # internal chart array set.... @@ -1339,7 +1337,7 @@ async def run_fsp( async for value in stream: # chart isn't actively shown so just skip render cycle - if chart._lc.isHidden(): + if chart.linked.isHidden(): continue now = time.time() diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py index a35a8d88..e5f7ffa4 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -119,7 +119,7 @@ class LineEditor: chart = cursor.active_plot y = cursor._datum_xy[1] - symbol = chart._lc.symbol + symbol = chart.linked.symbol # add a "staged" cursor-tracking line to view # and cash it in a a var @@ -199,7 +199,7 @@ class LineEditor: if not line: raise RuntimeError("No line is currently staged!?") - sym = chart._lc.symbol + sym = chart.linked.symbol line = order_line( chart, diff --git a/piker/ui/_label.py b/piker/ui/_label.py index 1e4af75e..cfe1351c 100644 --- a/piker/ui/_label.py +++ b/piker/ui/_label.py @@ -175,9 +175,6 @@ class Label: assert s_xy == self.txt.pos() - # def orient_on(self, h: str, v: str) -> None: - # pass - @property def fmt_str(self) -> str: return self._fmt_str diff --git a/piker/ui/_lines.py b/piker/ui/_lines.py index f0c137b2..c873857e 100644 --- a/piker/ui/_lines.py +++ b/piker/ui/_lines.py @@ -119,7 +119,7 @@ class LevelLine(pg.InfiniteLine): self._on_drag_start = lambda l: None self._on_drag_end = lambda l: None - self._y_incr_mult = 1 / chart._lc._symbol.tick_size + self._y_incr_mult = 1 / chart.linked.symbol.tick_size self._right_end_sc: float = 0 def txt_offsets(self) -> Tuple[int, int]: From dc279a48c24991ca36b137fb1267d158a4666a26 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 22 Jul 2021 11:43:18 -0400 Subject: [PATCH 024/178] Move DPI / screen get logging to debug; reduce cli noise --- piker/ui/_style.py | 2 +- piker/ui/_window.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/piker/ui/_style.py b/piker/ui/_style.py index 6e89d8b6..90f8a898 100644 --- a/piker/ui/_style.py +++ b/piker/ui/_style.py @@ -121,7 +121,7 @@ class DpiAwareFont: self._font_inches = inches font_size = math.floor(inches * dpi) - log.info( + log.debug( f"\nscreen:{screen.name()} with pDPI: {pdpi}, lDPI: {ldpi}" f"\nOur best guess font size is {font_size}\n" ) diff --git a/piker/ui/_window.py b/piker/ui/_window.py index c4c3f510..d05a6ed6 100644 --- a/piker/ui/_window.py +++ b/piker/ui/_window.py @@ -230,7 +230,7 @@ class MainWindow(QtGui.QMainWindow): for _ in range(3): screen = app.screenAt(self.pos()) - print('trying to access QScreen...') + log.debug('trying to access QScreen...') if screen is None: time.sleep(0.5) continue From a4028d3475fda0fd468271d3b299f8b49bb39200 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 22 Jul 2021 11:44:15 -0400 Subject: [PATCH 025/178] Actually position msgs get relayed verbatim --- piker/clearing/_messages.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/piker/clearing/_messages.py b/piker/clearing/_messages.py index 5667cb96..7db06c57 100644 --- a/piker/clearing/_messages.py +++ b/piker/clearing/_messages.py @@ -81,8 +81,6 @@ class Status(BaseModel): # 'alert_submitted', # 'alert_triggered', - # 'position', - # } resp: str # "response", see above From 568dd488b52b929dea3fca842a0ca27deb246c52 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 22 Jul 2021 12:05:53 -0400 Subject: [PATCH 026/178] Move level marker to annotate module --- piker/ui/_annotate.py | 155 ++++++++++++++++++++++++++++++++++++++--- piker/ui/_position.py | 156 +----------------------------------------- 2 files changed, 149 insertions(+), 162 deletions(-) diff --git a/piker/ui/_annotate.py b/piker/ui/_annotate.py index 372aae78..bd0f3c64 100644 --- a/piker/ui/_annotate.py +++ b/piker/ui/_annotate.py @@ -18,18 +18,20 @@ Annotations for ur faces. """ -import PyQt5 -from PyQt5 import QtCore, QtGui +from typing import Callable + +from PyQt5 import QtCore, QtGui, QtWidgets +from PyQt5.QtCore import QPointF from PyQt5.QtWidgets import QGraphicsPathItem from pyqtgraph import Point, functions as fn, Color import numpy as np +from ._anchors import marker_right_points + def mk_marker_path( - style, - # size: float = 20.0, - # use_path_type: type = QGraphicsPathItem + style: str, ) -> QGraphicsPathItem: """Add a marker to be displayed on the line wrapped in a ``QGraphicsPathItem`` @@ -83,13 +85,148 @@ def mk_marker_path( # self._maxMarkerSize = max([m[2] / 2. for m in self.markers]) - # if use_path_type: - # path = use_path_type(path) - # path.scale(size, size) - return path +class LevelMarker(QGraphicsPathItem): + '''An arrow marker path graphich which redraws itself + to the specified view coordinate level on each paint cycle. + + ''' + def __init__( + self, + chart: 'ChartPlotWidget', # noqa + style: str, + get_level: Callable[..., float], + size: float = 20, + keep_in_view: bool = True, + + ) -> None: + + # get polygon and scale + super().__init__() + self.scale(size, size) + + # interally generates path + self._style = None + self.style = style + + self.chart = chart + + self.get_level = get_level + self.scene_x = lambda: marker_right_points(chart)[1] + self.level: float = 0 + self.keep_in_view = keep_in_view + + assert self.path_br + + @property + def style(self) -> str: + return self._style + + @style.setter + def style(self, value: str) -> None: + if self._style != value: + polygon = mk_marker_path(value) + self.setPath(polygon) + self._style = value + + # get the path for the opaque path **without** weird + # surrounding margin + self.path_br = self.mapToScene( + self.path() + ).boundingRect() + + def delete(self) -> None: + self.scene().removeItem(self) + + @property + def h(self) -> float: + return self.path_br.height() + + @property + def w(self) -> float: + return self.path_br.width() + + def position_in_view( + self, + # level: float, + + ) -> None: + '''Show a pp off-screen indicator for a level label. + + This is like in fps games where you have a gps "nav" indicator + but your teammate is outside the range of view, except in 2D, on + the y-dimension. + + ''' + level = self.get_level() + + view = self.chart.getViewBox() + vr = view.state['viewRange'] + ymn, ymx = vr[1] + + # _, marker_right, _ = marker_right_points(line._chart) + x = self.scene_x() + + if level > ymx: # pin to top of view + self.setPos( + QPointF( + x, + self.h/3, + ) + ) + + elif level < ymn: # pin to bottom of view + + self.setPos( + QPointF( + x, + view.height() - 4/3*self.h, + ) + ) + + else: + # pp line is viewable so show marker normally + self.setPos( + x, + self.chart.view.mapFromView( + QPointF(0, self.get_level()) + ).y() + ) + + # marker = line._marker + if getattr(self, 'label', None): + label = self.label + + # re-anchor label (i.e. trigger call of ``arrow_tr()`` from above + label.update() + + def paint( + self, + + p: QtGui.QPainter, + opt: QtWidgets.QStyleOptionGraphicsItem, + w: QtWidgets.QWidget + + ) -> None: + '''Core paint which we override to always update + our marker position in scene coordinates from a + view cooridnate "level". + + ''' + if self.keep_in_view: + self.position_in_view() + + else: # just place at desired level even if not in view + self.setPos( + self.scene_x(), + self.mapToScene(QPointF(0, self.get_level())).y() + ) + + return super().paint(p, opt, w) + + def qgo_draw_markers( markers: list, diff --git a/piker/ui/_position.py b/piker/ui/_position.py index 3ce7ffea..5d528ff2 100644 --- a/piker/ui/_position.py +++ b/piker/ui/_position.py @@ -18,19 +18,18 @@ Position info and display """ -from typing import Optional, Callable +from typing import Optional from functools import partial from math import floor from pyqtgraph import functions as fn from pydantic import BaseModel -from PyQt5 import QtGui, QtWidgets from PyQt5.QtCore import QPointF from PyQt5.QtGui import QGraphicsPathItem -from ._annotate import mk_marker_path +from ._annotate import LevelMarker from ._anchors import ( - marker_right_points, + # marker_right_points, gpath_pin, # keep_marker_in_view, ) @@ -58,146 +57,6 @@ class Position(BaseModel): fills: list[Status] = [] -class LevelMarker(QGraphicsPathItem): - '''An arrow marker path graphich which redraws itself - to the specified view coordinate level on each paint cycle. - - ''' - def __init__( - self, - chart: 'ChartPlotWidget', # noqa - style: str, - get_level: Callable[..., float], - size: float = 20, - keep_in_view: bool = True, - - ) -> None: - - # get polygon and scale - super().__init__() - self.scale(size, size) - - # interally generates path - self._style = None - self.style = style - - self.chart = chart - - self.get_level = get_level - self.scene_x = lambda: marker_right_points(chart)[1] - self.level: float = 0 - self.keep_in_view = keep_in_view - - assert self.path_br - - @property - def style(self) -> str: - return self._style - - @style.setter - def style(self, value: str) -> None: - if self._style != value: - polygon = mk_marker_path(value) - self.setPath(polygon) - self._style = value - - # get the path for the opaque path **without** weird - # surrounding margin - self.path_br = self.mapToScene( - self.path() - ).boundingRect() - - - def delete(self) -> None: - self.scene().removeItem(self) - - @property - def h(self) -> float: - return self.path_br.height() - - @property - def w(self) -> float: - return self.path_br.width() - - def position_in_view( - self, - # level: float, - - ) -> None: - '''Show a pp off-screen indicator for a level label. - - This is like in fps games where you have a gps "nav" indicator - but your teammate is outside the range of view, except in 2D, on - the y-dimension. - - ''' - level = self.get_level() - - view = self.chart.getViewBox() - vr = view.state['viewRange'] - ymn, ymx = vr[1] - - # _, marker_right, _ = marker_right_points(line._chart) - x = self.scene_x() - - if level > ymx: # pin to top of view - self.setPos( - QPointF( - x, - self.h/3, - ) - ) - - elif level < ymn: # pin to bottom of view - - self.setPos( - QPointF( - x, - view.height() - 4/3*self.h, - ) - ) - - else: - # pp line is viewable so show marker normally - self.setPos( - x, - self.chart.view.mapFromView( - QPointF(0, self.get_level()) - ).y() - ) - - # marker = line._marker - if getattr(self, 'label', None): - label = self.label - - # re-anchor label (i.e. trigger call of ``arrow_tr()`` from above - label.update() - - def paint( - self, - - p: QtGui.QPainter, - opt: QtWidgets.QStyleOptionGraphicsItem, - w: QtWidgets.QWidget - - ) -> None: - '''Core paint which we override to always update - our marker position in scene coordinates from a - view cooridnate "level". - - ''' - if self.keep_in_view: - self.position_in_view() - - else: # just place at desired level even if not in view - self.setPos( - self.scene_x(), - self.mapToScene(QPointF(0, self.get_level())).y() - ) - - return super().paint(p, opt, w) - - class PositionTracker: '''Track and display a real-time position for a single symbol on a chart. @@ -366,11 +225,9 @@ class PositionTracker: if size > 0: style = '|<' - direction = 'up' elif size < 0: style = '>|' - direction = 'down' arrow = LevelMarker( chart=self.chart, @@ -378,13 +235,6 @@ class PositionTracker: get_level=self.level, size=arrow_size, ) - # _, marker_right, _ = marker_right_points(self.chart) - # arrow.scene_x = marker_right - - # monkey-cache height for sizing on pp nav-hub - # arrow._height = path_br.height() - # arrow._width = path_br.width() - arrow._direction = direction self.chart.getViewBox().scene().addItem(arrow) arrow.show() From 5144492534ee99d13257aa6e419ceffc246f5c2c Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 22 Jul 2021 13:21:58 -0400 Subject: [PATCH 027/178] Just warn for now on unknown dialogs --- piker/ui/order_mode.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index 7b9eb7fe..38791b9f 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -505,7 +505,14 @@ async def run_order_mode( resp = msg['resp'] oid = msg['oid'] - dialog = mode.dialogs[oid] + dialog = mode.dialogs.get(oid) + if dialog is None: + log.warning(f'received msg for untracked dialog:\n{fmsg}') + + # TODO: enable pure tracking / mirroring of dialogs + # is desired. + continue + # record message to dialog tracking dialog.msgs[oid] = msg From d283872eb6f7083a57dfecf4b6e028b31b58e203 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 22 Jul 2021 18:17:42 -0400 Subject: [PATCH 028/178] Add a scene bounding rect getter to our label --- piker/ui/_label.py | 6 ++++++ piker/ui/_lines.py | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/piker/ui/_label.py b/piker/ui/_label.py index cfe1351c..e2b3634e 100644 --- a/piker/ui/_label.py +++ b/piker/ui/_label.py @@ -135,6 +135,12 @@ class Label: def w(self) -> float: return self.txt.boundingRect().width() + def scene_br(self) -> QRectF: + txt = self.txt + return txt.mapToScene( + txt.boundingRect() + ).boundingRect() + @property def h(self) -> float: return self.txt.boundingRect().height() diff --git a/piker/ui/_lines.py b/piker/ui/_lines.py index c873857e..680a5604 100644 --- a/piker/ui/_lines.py +++ b/piker/ui/_lines.py @@ -314,9 +314,11 @@ class LevelLine(pg.InfiniteLine): def paint( self, + p: QtGui.QPainter, opt: QtWidgets.QStyleOptionGraphicsItem, w: QtWidgets.QWidget + ) -> None: """Core paint which we override (yet again) from pg.. @@ -348,6 +350,8 @@ class LevelLine(pg.InfiniteLine): # order lines.. not sure wtf is up with that. # for now we're just using it on the position line. elif self._marker: + # print('we have a marker?') + # self._chart.view.locate(self._marker) # TODO: make this label update part of a scene-aware-marker # composed annotation From 5fb00f726e3e0606c91c884999fd85fe79d7c5e4 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 22 Jul 2021 18:42:23 -0400 Subject: [PATCH 029/178] Add support for a marker "on paint" callback --- piker/ui/_annotate.py | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/piker/ui/_annotate.py b/piker/ui/_annotate.py index bd0f3c64..2a032cb7 100644 --- a/piker/ui/_annotate.py +++ b/piker/ui/_annotate.py @@ -18,10 +18,10 @@ Annotations for ur faces. """ -from typing import Callable +from typing import Callable, Optional from PyQt5 import QtCore, QtGui, QtWidgets -from PyQt5.QtCore import QPointF +from PyQt5.QtCore import QPointF, QRectF from PyQt5.QtWidgets import QGraphicsPathItem from pyqtgraph import Point, functions as fn, Color import numpy as np @@ -100,6 +100,7 @@ class LevelMarker(QGraphicsPathItem): get_level: Callable[..., float], size: float = 20, keep_in_view: bool = True, + on_paint: Optional[Callable] = None, ) -> None: @@ -114,12 +115,11 @@ class LevelMarker(QGraphicsPathItem): self.chart = chart self.get_level = get_level + self._on_paint = on_paint self.scene_x = lambda: marker_right_points(chart)[1] self.level: float = 0 self.keep_in_view = keep_in_view - assert self.path_br - @property def style(self) -> str: return self._style @@ -131,22 +131,25 @@ class LevelMarker(QGraphicsPathItem): self.setPath(polygon) self._style = value - # get the path for the opaque path **without** weird - # surrounding margin - self.path_br = self.mapToScene( - self.path() - ).boundingRect() + def path_br(self) -> QRectF: + '''Return the bounding rect for the opaque path part + of this item. + + ''' + return self.mapToScene( + self.path() + ).boundingRect() def delete(self) -> None: self.scene().removeItem(self) @property def h(self) -> float: - return self.path_br.height() + return self.path_br().height() @property def w(self) -> float: - return self.path_br.width() + return self.path_br().width() def position_in_view( self, @@ -195,13 +198,6 @@ class LevelMarker(QGraphicsPathItem): ).y() ) - # marker = line._marker - if getattr(self, 'label', None): - label = self.label - - # re-anchor label (i.e. trigger call of ``arrow_tr()`` from above - label.update() - def paint( self, @@ -224,7 +220,10 @@ class LevelMarker(QGraphicsPathItem): self.mapToScene(QPointF(0, self.get_level())).y() ) - return super().paint(p, opt, w) + super().paint(p, opt, w) + + if self._on_paint: + self._on_paint(self) def qgo_draw_markers( From 3fb0e02788b283b9e929293071c256f4df7263bd Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 23 Jul 2021 12:00:48 -0400 Subject: [PATCH 030/178] Factor font-size-based labeled-line edit into generics widget --- piker/ui/_search.py | 69 +++++++++++++++++++++++++++++++++------------ 1 file changed, 51 insertions(+), 18 deletions(-) diff --git a/piker/ui/_search.py b/piker/ui/_search.py index af72d421..c6bd5902 100644 --- a/piker/ui/_search.py +++ b/piker/ui/_search.py @@ -71,7 +71,6 @@ from ..log import get_logger from ._style import ( _font, DpiAwareFont, - # hcolor, ) @@ -425,27 +424,22 @@ class CompleterView(QTreeView): self.resize() -class SearchBar(QtWidgets.QLineEdit): - - mode_name: str = 'mode: search' +class FontAndChartAwareLineEdit(QtWidgets.QLineEdit): def __init__( self, parent: QWidget, parent_chart: QWidget, # noqa - view: Optional[CompleterView] = None, font: DpiAwareFont = _font, ) -> None: - super().__init__(parent) # self.setContextMenuPolicy(Qt.CustomContextMenu) # self.customContextMenuRequested.connect(self.show_menu) # self.setStyleSheet(f"font: 18px") - self.view: CompleterView = view self.dpi_font = font self.godwidget = parent_chart @@ -460,14 +454,9 @@ class SearchBar(QtWidgets.QLineEdit): # witty bit of margin self.setTextMargins(2, 2, 2, 2) - def focus(self) -> None: - self.selectAll() - self.show() - self.setFocus() - - def show(self) -> None: - super().show() - self.view.show_matches() + # chart count which will be used to calculate + # width of input field. + self._chars: int = 9 def sizeHint(self) -> QtCore.QSize: """ @@ -475,9 +464,52 @@ class SearchBar(QtWidgets.QLineEdit): """ psh = super().sizeHint() - psh.setHeight(self.dpi_font.px_size + 2) + + dpi_font = self.dpi_font + char_w_pxs = dpi_font.boundingRect('A').width() + + # space for ``._chars: int`` + chars_w = self._chars * char_w_pxs * dpi_font.scale() + + psh.setHeight(dpi_font.px_size + 2) + psh.setWidth(chars_w) return psh + def set_width_in_chars( + self, + chars: int, + + ) -> None: + self._chars = chars + self.sizeHint() + self.update() + + def focus(self) -> None: + self.selectAll() + self.show() + self.setFocus() + + +class SearchBar(FontAndChartAwareLineEdit): + + mode_name: str = 'mode: search' + + def __init__( + + self, + parent: QWidget, + view: Optional[CompleterView] = None, + **kwargs, + + ) -> None: + + super().__init__(parent, **kwargs) + self.view: CompleterView = view + + def show(self) -> None: + super().show() + self.view.show_matches() + def unfocus(self) -> None: self.parent().hide() self.clearFocus() @@ -540,8 +572,8 @@ class SearchWidget(QtWidgets.QWidget): ) self.bar = SearchBar( parent=self, - parent_chart=godwidget, view=self.view, + parent_chart=godwidget, ) self.bar_hbox.addWidget(self.bar) @@ -599,11 +631,12 @@ class SearchWidget(QtWidgets.QWidget): def chart_current_item( self, clear_to_cache: bool = True, + ) -> Optional[str]: '''Attempt to load and switch the current selected completion result to the affiliated chart app. - Return any loaded symbol + Return any loaded symbol. ''' value = self.get_current_item() From 5473c9848d1476737a8b9f4c03317f625c3699fd Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 23 Jul 2021 12:01:09 -0400 Subject: [PATCH 031/178] Start a "text entry widgets" module --- piker/ui/_text_entry.py | 81 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 piker/ui/_text_entry.py diff --git a/piker/ui/_text_entry.py b/piker/ui/_text_entry.py new file mode 100644 index 00000000..38d624c4 --- /dev/null +++ b/piker/ui/_text_entry.py @@ -0,0 +1,81 @@ +# 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 . + +''' +Text entry widgets (mostly for configuration). + +''' +from PyQt5 import QtCore, QtGui +from PyQt5 import QtWidgets + +from ._search import FontAndChartAwareLineEdit +from ._style import hcolor, _font + + +class LabeledTextInput(QtGui.QWidget): + + def __init__( + self, + godwidget: 'GodWidget', # type: ignore # noqa + parent=None, + + ) -> None: + super().__init__(parent or godwidget) + + # size it as we specify + self.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, + QtWidgets.QSizePolicy.Fixed, + ) + + self.godwidget = godwidget + + # split layout for the (label:| text bar entry) + self.hbox = QtGui.QHBoxLayout(self) + self.hbox.setContentsMargins(0, 0, 0, 0) + self.hbox.setSpacing(4) + + # add label to left of search bar + self.label = label = QtGui.QLabel(parent=self) + label.setTextFormat(3) # markdown + label.setFont(_font.font) + label.setStyleSheet( + f"QLabel {{ color : {hcolor('gunmetal')}; }}" + ) + label.setMargin(4) + label.setText("`$cap:`") + label.setAlignment( + QtCore.Qt.AlignVCenter + | QtCore.Qt.AlignLeft + ) + label.show() + + self.hbox.addWidget(label) + + self.edit = FontAndChartAwareLineEdit( + parent=self, + parent_chart=godwidget, + ) + self.edit.set_width_in_chars(6) + self.edit.setText('5000') + self.hbox.addWidget(self.edit) + + def sizeHint(self) -> QtCore.QSize: + """ + Scale edit box to size of dpi aware font. + + """ + return self.edit.sizeHint() From da3f14964682e71776de5f661c5187c21dc56f9d Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 23 Jul 2021 12:17:29 -0400 Subject: [PATCH 032/178] Add a tight pp anchor --- piker/ui/_anchors.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/piker/ui/_anchors.py b/piker/ui/_anchors.py index d8fdb4c4..f651474a 100644 --- a/piker/ui/_anchors.py +++ b/piker/ui/_anchors.py @@ -45,7 +45,7 @@ def marker_right_points( ryaxis = chart.getAxis('right') r_axis_x = ryaxis.pos().x() - up_to_l1_sc = r_axis_x - l1_len + up_to_l1_sc = r_axis_x - l1_len - 10 marker_right = up_to_l1_sc - (1.375 * 2 * marker_size) line_end = marker_right - (6/16 * marker_size) @@ -129,7 +129,7 @@ def gpath_pin( # label.vb.locate(label.txt) #, children=True) if location_description == 'right-of-path-centered': - return path_br.topRight() - QPointF(0, label.h / 3) + return path_br.topRight() - QPointF(label.h/16, label.h / 3) if location_description == 'left-of-path-centered': return path_br.topLeft() - QPointF(label.w, label.h / 6) @@ -139,3 +139,15 @@ def gpath_pin( elif location_description == 'below-path-right-aligned': return path_br.bottomRight() - QPointF(label.w, label.h / 6) + + + +def pp_tight_and_right( + label: Label + +) -> QPointF: + '''Place *just* right of the pp label. + + ''' + txt = label.txt + return label.txt.pos() + QPointF(label.w - label.h/3, 0) From 4ce6edae70cd6c274e673816b9b016ccfd51df25 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 23 Jul 2021 12:17:59 -0400 Subject: [PATCH 033/178] Skip line stage when chart not yet initialized --- piker/ui/_editors.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py index e5f7ffa4..060ca9b0 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -111,6 +111,10 @@ class LineEditor: and return it. """ + if self.chart is None: + log.error('No chart interaction yet available') + return None + # chart.setCursor(QtCore.Qt.PointingHandCursor) cursor = self.chart.linked.cursor if not cursor: From 4d5afc2e2521b84b127939eecae0b238aead1963 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 23 Jul 2021 12:19:07 -0400 Subject: [PATCH 034/178] Add dpi font scale getter --- piker/ui/_style.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/piker/ui/_style.py b/piker/ui/_style.py index 90f8a898..2b73b0df 100644 --- a/piker/ui/_style.py +++ b/piker/ui/_style.py @@ -56,7 +56,6 @@ class DpiAwareFont: self._qfont = QtGui.QFont(name) self._font_size: str = font_size self._qfm = QtGui.QFontMetrics(self._qfont) - self._physical_dpi = None self._font_inches: float = None self._screen = None @@ -82,6 +81,10 @@ class DpiAwareFont: def font(self): return self._qfont + def scale(self) -> float: + screen = self.screen + return screen.logicalDotsPerInch() / screen.physicalDotsPerInch() + @property def px_size(self) -> int: return self._qfont.pixelSize() @@ -114,7 +117,7 @@ class DpiAwareFont: # dpi is likely somewhat scaled down so use slightly larger font size if scale > 1 and self._font_size: # TODO: this denominator should probably be determined from - # relative aspect rations or something? + # relative aspect ratios or something? inches = inches * (1 / scale) * (1 + 6/16) dpi = mx_dpi From c4a9d533067d24ddbfc5502cb785e3de76972a8a Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 23 Jul 2021 14:19:22 -0400 Subject: [PATCH 035/178] Use one marker, drop old anchors, add graphics update on marker paint --- piker/ui/_position.py | 139 ++++++++++++++++++++++++------------------ 1 file changed, 79 insertions(+), 60 deletions(-) diff --git a/piker/ui/_position.py b/piker/ui/_position.py index 5d528ff2..7f1ee14b 100644 --- a/piker/ui/_position.py +++ b/piker/ui/_position.py @@ -24,14 +24,13 @@ from math import floor from pyqtgraph import functions as fn from pydantic import BaseModel -from PyQt5.QtCore import QPointF -from PyQt5.QtGui import QGraphicsPathItem +# from PyQt5.QtCore import QPointF +# from PyQt5.QtGui import QGraphicsPathItem from ._annotate import LevelMarker from ._anchors import ( - # marker_right_points, + pp_tight_and_right, # wanna keep it straight in the long run gpath_pin, - # keep_marker_in_view, ) from ..clearing._messages import BrokerdPosition, Status from ..data._source import Symbol @@ -90,10 +89,6 @@ class PositionTracker: view = chart.getViewBox() - # create placeholder 'up' level arrow - self._level_marker = None - self._level_marker = self.level_marker(size=1) - # literally 'pp' label that's always in view self.pp_label = pp_label = Label( view=view, @@ -102,7 +97,9 @@ class PositionTracker: update_on_range_change=False, ) - self._level_marker.label = pp_label + # create placeholder 'up' level arrow + self._level_marker = None + self._level_marker = self.level_marker(size=1) pp_label.scene_anchor = partial( gpath_pin, @@ -110,7 +107,6 @@ class PositionTracker: label=pp_label, ) pp_label.render() - pp_label.show() self.size_label = size_label = Label( view=view, @@ -119,7 +115,7 @@ class PositionTracker: # this is "static" label # update_on_range_change=False, fmt_str='\n'.join(( - 'x{entry_size}', + ':{entry_size:.0f}', )), fields={ @@ -127,12 +123,20 @@ class PositionTracker: }, ) size_label.render() - # size_label.scene_anchor = self.align_to_marker - size_label.scene_anchor = lambda: ( - self.pp_label.txt.pos() + QPointF(self.pp_label.w, 0) + size_label.scene_anchor = partial( + pp_tight_and_right, + label=self.pp_label, ) - size_label.hide() + + # size_label.scene_anchor = self.align_to_marker + # size_label.scene_anchor = lambda: ( + # self.pp_label.txt.pos() + QPointF(self.pp_label.w, 0) + # ) + # size_label.scene_anchor = lambda: ( + # self.pp_label.scene_br().bottomRight() - QPointF( + # self.size_label.w, self.size_label.h/3) + # ) # TODO: if we want to show more position-y info? # fmt_str='\n'.join(( @@ -150,6 +154,20 @@ class PositionTracker: # }, # ) + def update_graphics( + self, + marker: LevelMarker + + ) -> None: + '''Update all labels. + + Meant to be called from the maker ``.paint()`` + for immediate, lag free label draws. + + ''' + self.pp_label.update() + self.size_label.update() + def update( self, msg: BrokerdPosition, @@ -174,11 +192,17 @@ class PositionTracker: else: self._level_marker.level = avg_price + + # these updates are critical to avoid lag on view/scene changes self._level_marker.update() # trigger paint + self.pp_label.update() + self.size_label.update() + self.show() - # self.pp_label.show() - # self._level_marker.show() + # don't show side and status widgets unless + # order mode is "engaged" (which done via input controls) + self.hide_info() def level(self) -> float: if self.line: @@ -201,17 +225,18 @@ class PositionTracker: self.line.hide() def hide_info(self) -> None: - '''Hide details of position. + '''Hide details (right now just size label?) of position. ''' # TODO: add remove status bar widgets here self.size_label.hide() + # TODO: move into annoate module def level_marker( self, size: float, - ) -> QGraphicsPathItem: + ) -> LevelMarker: if self._level_marker: self._level_marker.delete() @@ -234,19 +259,12 @@ class PositionTracker: style=style, get_level=self.level, size=arrow_size, + on_paint=self.update_graphics, ) self.chart.getViewBox().scene().addItem(arrow) arrow.show() - # arrow.label = self.pp_label - - # inside ``LevelLine.pain()`` this is updates... - # we need a better way to have the label updated as frequenty - # as every paint call? Maybe use a better slot then the range - # change? - # self._level_marker.label = self.pp_label - return arrow def position_line( @@ -298,50 +316,51 @@ class PositionTracker: return line + # TODO: we can drop this right? # order line endpoint anchor - def align_to_marker(self) -> QPointF: + # def align_to_marker(self) -> QPointF: - pp_line = self.line - if pp_line: + # pp_line = self.line + # if pp_line: - # line_ep = pp_line.scene_endpoint() - # print(line_ep) + # # line_ep = pp_line.scene_endpoint() + # # print(line_ep) - # y_level_scene = line_ep.y() - # pp_y = pp_label.txt.pos().y() + # # y_level_scene = line_ep.y() + # # pp_y = pp_label.txt.pos().y() - # if y_level_scene > pp_y: - # y_level_scene = pp_y + # # if y_level_scene > pp_y: + # # y_level_scene = pp_y - # elif y_level_scene - mkr_pos = self._level_marker.pos() + # # elif y_level_scene + # mkr_pos = self._level_marker.pos() - left_of_mkr = QPointF( - # line_ep.x() - self.size_label.w, - mkr_pos.x() - self.size_label.w, - mkr_pos.y(), - # self._level_marker - # max(0, y_level_scene), - # min( - # pp_label.txt.pos().y() - # ), - ) - return left_of_mkr + # left_of_mkr = QPointF( + # # line_ep.x() - self.size_label.w, + # mkr_pos.x() - self.size_label.w, + # mkr_pos.y(), + # # self._level_marker + # # max(0, y_level_scene), + # # min( + # # pp_label.txt.pos().y() + # # ), + # ) + # return left_of_mkr - # return QPointF( + # # return QPointF( - # marker_right_points(chart)[2] - pp_label.w , - # view.height() - pp_label.h, - # # br.x() - pp_label.w, - # # br.y(), - # ) + # # marker_right_points(chart)[2] - pp_label.w , + # # view.height() - pp_label.h, + # # # br.x() - pp_label.w, + # # # br.y(), + # # ) - else: - # pp = _lines._pp_label.txt - # scene_rect = pp.mapToScene(pp.boundingRect()).boundingRect() - # br = scene_rect.bottomRight() + # else: + # # pp = _lines._pp_label.txt + # # scene_rect = pp.mapToScene(pp.boundingRect()).boundingRect() + # # br = scene_rect.bottomRight() - return QPointF(0, 0) + # return QPointF(0, 0) def update_line( self, From 0f176425b1b0443f224edcdcd0bd764d3c6791c2 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 23 Jul 2021 14:20:29 -0400 Subject: [PATCH 036/178] First WIP of pp config entry widget on status bar --- piker/ui/order_mode.py | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index 38791b9f..6592628e 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -34,7 +34,8 @@ from ..log import get_logger from ._editors import LineEditor, ArrowEditor from ._lines import LevelLine from ._position import PositionTracker -from ._window import MultiStatus, main_window +from ._window import MultiStatus +from ._text_entry import LabeledTextInput log = get_logger(__name__) @@ -80,10 +81,9 @@ class OrderMode: book: OrderBook lines: LineEditor arrows: ArrowEditor - status_bar: MultiStatus - - # pp status info - # label: Label + multistatus: MultiStatus + pp: PositionTracker + pp_config: LabeledTextInput name: str = 'order' @@ -281,7 +281,7 @@ class OrderMode: dialog = OrderDialog( uuid=oid, line=line, - last_status_close=self.status_bar.open_status( + last_status_close=self.multistatus.open_status( f'submitting {self._exec_mode}-{action}', final_msg=f'submitted {self._exec_mode}-{action}', clear_on_next=True, @@ -333,7 +333,7 @@ class OrderMode: ids: list = [] if lines: - key = self.status_bar.open_status( + key = self.multistatus.open_status( f'cancelling {len(lines)} orders', final_msg=f'cancelled {len(lines)} orders', group_key=True @@ -346,7 +346,7 @@ class OrderMode: if dialog: oid = dialog.uuid - cancel_status_close = self.status_bar.open_status( + cancel_status_close = self.multistatus.open_status( f'cancelling order {oid[:6]}', group_key=key, ) @@ -396,7 +396,8 @@ async def run_order_mode( - begin order handling loop ''' - done = chart.window().status_bar.open_status('starting order mode..') + multistatus = chart.window().status_bar + done = multistatus.open_status('starting order mode..') book: OrderBook trades_stream: tractor.MsgStream @@ -411,11 +412,7 @@ async def run_order_mode( positions ), - # # start async input handling for chart's view - # # await godwidget._task_stack.enter_async_context( - # chart._vb.open_async_input_handler(), ): - status_bar: MultiStatus = main_window().status_bar view = chart._vb lines = LineEditor(chart=chart) arrows = ArrowEditor(chart, {}) @@ -423,18 +420,26 @@ async def run_order_mode( log.info("Opening order mode") pp = PositionTracker(chart) + pp.hide() + + # insert order mode config to left of mode label + pp_config = LabeledTextInput(chart.linked.godwidget) + sb = chart.window().statusBar() + sb.insertPermanentWidget(0, pp_config) + pp_config.hide() mode = OrderMode( chart, book, lines, arrows, - status_bar, + multistatus, + pp, + pp_config, ) # so that view handlers can access it mode.pp = pp - view.mode = mode asset_type = symbol.type_key From 64ccc79a3364687ac80badacf94321cb2da6588b Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 23 Jul 2021 14:21:10 -0400 Subject: [PATCH 037/178] Change order label format to color:count --- piker/ui/_lines.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/piker/ui/_lines.py b/piker/ui/_lines.py index 680a5604..e94d1b8c 100644 --- a/piker/ui/_lines.py +++ b/piker/ui/_lines.py @@ -350,8 +350,6 @@ class LevelLine(pg.InfiniteLine): # order lines.. not sure wtf is up with that. # for now we're just using it on the position line. elif self._marker: - # print('we have a marker?') - # self._chart.view.locate(self._marker) # TODO: make this label update part of a scene-aware-marker # composed annotation @@ -702,7 +700,7 @@ def order_line( # display the order pos size, which is some multiple # of the user defined base unit size - fmt_str=('x{size:.0f}'), + fmt_str=(':{size:.0f}'), # fmt_str=('{size:.{size_digits}f}'), # old color=line.color, ) From 8d65a55f9e9b62442e3d46200a6f061356e86eef Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 23 Jul 2021 14:21:39 -0400 Subject: [PATCH 038/178] Toggle pp config widget on order mode active --- piker/ui/_interaction.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 4f4b00b6..267fa699 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -175,6 +175,8 @@ async def handle_viewmode_inputs( # show the pp label mode.pp.show() + # show pp config params in status bar widget + mode.pp_config.show() if ( # 's' for "submit" to activate "live" order @@ -211,6 +213,7 @@ async def handle_viewmode_inputs( # hide pp label mode.pp.hide_info() + mode.pp_config.hide() # if none are pressed, remove "staged" level # line under cursor position From 1ed7be7c00998cb7ef6134f0f7b0b9ff9dbcc254 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 24 Jul 2021 16:04:58 -0400 Subject: [PATCH 039/178] Move font-aware line edit to "text entry" mod --- piker/ui/_search.py | 77 ++----------------- piker/ui/_text_entry.py | 166 +++++++++++++++++++++++++++++++++++----- 2 files changed, 154 insertions(+), 89 deletions(-) diff --git a/piker/ui/_search.py b/piker/ui/_search.py index c6bd5902..542f9e7b 100644 --- a/piker/ui/_search.py +++ b/piker/ui/_search.py @@ -72,6 +72,7 @@ from ._style import ( _font, DpiAwareFont, ) +from ._text_entry import FontAndChartAwareLineEdit log = get_logger(__name__) @@ -95,7 +96,7 @@ class SimpleDelegate(QStyledItemDelegate): class CompleterView(QTreeView): - mode_name: str = 'mode: search-nav' + mode_name: str = 'search-nav' # XXX: relevant docs links: # - simple widget version of this: @@ -424,85 +425,21 @@ class CompleterView(QTreeView): self.resize() -class FontAndChartAwareLineEdit(QtWidgets.QLineEdit): - - def __init__( - - self, - parent: QWidget, - parent_chart: QWidget, # noqa - font: DpiAwareFont = _font, - - ) -> None: - super().__init__(parent) - - # self.setContextMenuPolicy(Qt.CustomContextMenu) - # self.customContextMenuRequested.connect(self.show_menu) - # self.setStyleSheet(f"font: 18px") - - self.dpi_font = font - self.godwidget = parent_chart - - # size it as we specify - # https://doc.qt.io/qt-5/qsizepolicy.html#Policy-enum - self.setSizePolicy( - QtWidgets.QSizePolicy.Expanding, - QtWidgets.QSizePolicy.Fixed, - ) - self.setFont(font.font) - - # witty bit of margin - self.setTextMargins(2, 2, 2, 2) - - # chart count which will be used to calculate - # width of input field. - self._chars: int = 9 - - def sizeHint(self) -> QtCore.QSize: - """ - Scale edit box to size of dpi aware font. - - """ - psh = super().sizeHint() - - dpi_font = self.dpi_font - char_w_pxs = dpi_font.boundingRect('A').width() - - # space for ``._chars: int`` - chars_w = self._chars * char_w_pxs * dpi_font.scale() - - psh.setHeight(dpi_font.px_size + 2) - psh.setWidth(chars_w) - return psh - - def set_width_in_chars( - self, - chars: int, - - ) -> None: - self._chars = chars - self.sizeHint() - self.update() - - def focus(self) -> None: - self.selectAll() - self.show() - self.setFocus() - - class SearchBar(FontAndChartAwareLineEdit): - mode_name: str = 'mode: search' + mode_name: str = 'search' def __init__( self, parent: QWidget, + godwidget: QWidget, view: Optional[CompleterView] = None, **kwargs, ) -> None: + self.godwidget = godwidget super().__init__(parent, **kwargs) self.view: CompleterView = view @@ -524,7 +461,7 @@ class SearchWidget(QtWidgets.QWidget): Includes helper methods for item management in the sub-widgets. ''' - mode_name: str = 'mode: search' + mode_name: str = 'search' def __init__( self, @@ -573,7 +510,7 @@ class SearchWidget(QtWidgets.QWidget): self.bar = SearchBar( parent=self, view=self.view, - parent_chart=godwidget, + godwidget=godwidget, ) self.bar_hbox.addWidget(self.bar) diff --git a/piker/ui/_text_entry.py b/piker/ui/_text_entry.py index 38d624c4..49fb62b1 100644 --- a/piker/ui/_text_entry.py +++ b/piker/ui/_text_entry.py @@ -18,22 +18,97 @@ Text entry widgets (mostly for configuration). ''' +from typing import Optional + +# import trio from PyQt5 import QtCore, QtGui from PyQt5 import QtWidgets +from PyQt5.QtWidgets import QWidget -from ._search import FontAndChartAwareLineEdit -from ._style import hcolor, _font +from ._style import hcolor, _font, DpiAwareFont -class LabeledTextInput(QtGui.QWidget): +class FontAndChartAwareLineEdit(QtWidgets.QLineEdit): + + def __init__( + + self, + parent: QWidget, + # parent_chart: QWidget, # noqa + font: DpiAwareFont = _font, + width_in_chars: int = None, + + ) -> None: + + # self.setContextMenuPolicy(Qt.CustomContextMenu) + # self.customContextMenuRequested.connect(self.show_menu) + # self.setStyleSheet(f"font: 18px") + + self.dpi_font = font + # self.godwidget = parent_chart + + if width_in_chars: + self._chars = int(width_in_chars) + + else: + # chart count which will be used to calculate + # width of input field. + self._chars: int = 9 + + super().__init__(parent) + # size it as we specify + # https://doc.qt.io/qt-5/qsizepolicy.html#Policy-enum + self.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Fixed, + ) + self.setFont(font.font) + + # witty bit of margin + self.setTextMargins(2, 2, 2, 2) + + def sizeHint(self) -> QtCore.QSize: + """ + Scale edit box to size of dpi aware font. + + """ + psh = super().sizeHint() + + dpi_font = self.dpi_font + psh.setHeight(dpi_font.px_size + 2) + + # space for ``._chars: int`` + char_w_pxs = dpi_font.boundingRect(self.text()).width() + chars_w = char_w_pxs + 6 # * dpi_font.scale() * self._chars + psh.setWidth(chars_w) + + return psh + + def set_width_in_chars( + self, + chars: int, + + ) -> None: + self._chars = chars + self.sizeHint() + self.update() + + def focus(self) -> None: + self.selectAll() + self.show() + self.setFocus() + + +class FieldsForm(QtGui.QWidget): def __init__( self, - godwidget: 'GodWidget', # type: ignore # noqa + + # godwidget: 'GodWidget', # type: ignore # noqa parent=None, ) -> None: - super().__init__(parent or godwidget) + super().__init__(parent) # size it as we specify self.setSizePolicy( @@ -41,22 +116,33 @@ class LabeledTextInput(QtGui.QWidget): QtWidgets.QSizePolicy.Fixed, ) - self.godwidget = godwidget - # split layout for the (label:| text bar entry) self.hbox = QtGui.QHBoxLayout(self) - self.hbox.setContentsMargins(0, 0, 0, 0) - self.hbox.setSpacing(4) + self.hbox.setContentsMargins(16, 0, 16, 0) + self.hbox.setSpacing(3) + + def add_field( + self, + + name: str, + value: str, + + widget: Optional[QWidget] = None, + + ) -> None: # add label to left of search bar self.label = label = QtGui.QLabel(parent=self) label.setTextFormat(3) # markdown label.setFont(_font.font) label.setStyleSheet( - f"QLabel {{ color : {hcolor('gunmetal')}; }}" + f"QLabel {{ color : {hcolor('papas_special')}; }}" ) label.setMargin(4) - label.setText("`$cap:`") + + # name = "share cap:" + label.setText(name) + label.setAlignment( QtCore.Qt.AlignVCenter | QtCore.Qt.AlignLeft @@ -67,15 +153,57 @@ class LabeledTextInput(QtGui.QWidget): self.edit = FontAndChartAwareLineEdit( parent=self, - parent_chart=godwidget, + # parent_chart=self.godwidget, + # width_in_chars=6, ) - self.edit.set_width_in_chars(6) - self.edit.setText('5000') + self.edit.setStyleSheet( + f"QLineEdit {{ color : {hcolor('gunmetal')}; }}" + ) + self.edit.setText(str(value)) self.hbox.addWidget(self.edit) - def sizeHint(self) -> QtCore.QSize: - """ - Scale edit box to size of dpi aware font. - """ - return self.edit.sizeHint() +# async def handle_form_input( + +# chart: 'ChartPlotWidget', # noqa +# form: FieldsForm, +# recv_chan: trio.abc.ReceiveChannel, + +# ) -> None: + +# async for event, etype, key, mods, txt in recv_chan: +# print(f'key: {key}, mods: {mods}, txt: {txt}') + +# ctl = False +# if mods == Qt.ControlModifier: +# ctl = True + +# # cancel and close +# if ctl and key in { +# Qt.Key_C, +# Qt.Key_Space, # i feel like this is the "native" one +# Qt.Key_Alt, +# }: +# # search.bar.unfocus() + +# # kill the search and focus back on main chart +# if chart: +# chart.linkedsplits.focus() + +# continue + + +def mk_form( + + parent: QWidget, + fields: dict, + # orientation: str = 'horizontal', + +) -> FieldsForm: + + form = FieldsForm(parent) + + for name, value in fields.items(): + form.add_field(name, value) + + return form From 97f4d9bc2db984e72a3b97a00d9c6de663876b10 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 24 Jul 2021 16:05:46 -0400 Subject: [PATCH 040/178] Drop stale anchors --- piker/ui/_position.py | 47 ------------------------------------------- 1 file changed, 47 deletions(-) diff --git a/piker/ui/_position.py b/piker/ui/_position.py index 7f1ee14b..65f1c2ac 100644 --- a/piker/ui/_position.py +++ b/piker/ui/_position.py @@ -129,7 +129,6 @@ class PositionTracker: label=self.pp_label, ) - # size_label.scene_anchor = self.align_to_marker # size_label.scene_anchor = lambda: ( # self.pp_label.txt.pos() + QPointF(self.pp_label.w, 0) # ) @@ -316,52 +315,6 @@ class PositionTracker: return line - # TODO: we can drop this right? - # order line endpoint anchor - # def align_to_marker(self) -> QPointF: - - # pp_line = self.line - # if pp_line: - - # # line_ep = pp_line.scene_endpoint() - # # print(line_ep) - - # # y_level_scene = line_ep.y() - # # pp_y = pp_label.txt.pos().y() - - # # if y_level_scene > pp_y: - # # y_level_scene = pp_y - - # # elif y_level_scene - # mkr_pos = self._level_marker.pos() - - # left_of_mkr = QPointF( - # # line_ep.x() - self.size_label.w, - # mkr_pos.x() - self.size_label.w, - # mkr_pos.y(), - # # self._level_marker - # # max(0, y_level_scene), - # # min( - # # pp_label.txt.pos().y() - # # ), - # ) - # return left_of_mkr - - # # return QPointF( - - # # marker_right_points(chart)[2] - pp_label.w , - # # view.height() - pp_label.h, - # # # br.x() - pp_label.w, - # # # br.y(), - # # ) - - # else: - # # pp = _lines._pp_label.txt - # # scene_rect = pp.mapToScene(pp.boundingRect()).boundingRect() - # # br = scene_rect.bottomRight() - - # return QPointF(0, 0) - def update_line( self, From 00ff0e96cdc0caf93773ed81560ff9fa8562eaba Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 24 Jul 2021 16:06:05 -0400 Subject: [PATCH 041/178] Add mode name setter --- piker/ui/_window.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/piker/ui/_window.py b/piker/ui/_window.py index d05a6ed6..6be62653 100644 --- a/piker/ui/_window.py +++ b/piker/ui/_window.py @@ -165,7 +165,7 @@ class MainWindow(QtGui.QMainWindow): self._status_label = label = QtGui.QLabel() label.setStyleSheet( - f"QLabel {{ color : {hcolor('gunmetal')}; }}" + f"QLabel {{ color : {hcolor('papas_special')}; }}" ) label.setTextFormat(3) # markdown label.setFont(_font_small.font) @@ -186,6 +186,8 @@ class MainWindow(QtGui.QMainWindow): """Cancel the root actor asap. """ + # TODO: instead kill the root tractor nursery? + # raising KBI seems to get intercepted by by Qt so just use the system. os.kill(os.getpid(), signal.SIGINT) @@ -209,10 +211,20 @@ class MainWindow(QtGui.QMainWindow): return self._status_bar + def set_mode_name( + self, + name: str, + + ) -> None: + + self.mode_label.setText(f'mode:{name}') + def on_focus_change( self, + old: QtGui.QWidget, new: QtGui.QWidget, + ) -> None: log.debug(f'widget focus changed from {old} -> {new}') @@ -220,7 +232,7 @@ class MainWindow(QtGui.QMainWindow): if new is not None: # cursor left window? name = getattr(new, 'mode_name', '') - self.mode_label.setText(name) + self.set_mode_name(name) def current_screen(self) -> QtGui.QScreen: """Get a frickin screen (if we can, gawd). From e8e9e20124c3ddf217ac0982e1b7d9a9a902a395 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 24 Jul 2021 16:07:04 -0400 Subject: [PATCH 042/178] Use mode name setter throughout --- piker/ui/_chart.py | 2 +- piker/ui/_interaction.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index d23e93b8..a162d76a 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -456,7 +456,7 @@ class ChartPlotWidget(pg.PlotWidget): _l1_labels: L1Labels = None - mode_name: str = 'mode: view' + mode_name: str = 'view' # TODO: can take a ``background`` color setting - maybe there's # a better one? diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 267fa699..4e5f8017 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -206,8 +206,7 @@ async def handle_viewmode_inputs( view.mode.set_exec(action) prefix = trigger_mode + '-' if action != 'alert' else '' - view._chart.window().mode_label.setText( - f'mode: {prefix}{action}') + view._chart.window().set_mode_name(f'{prefix}{action}') else: # none active @@ -221,7 +220,7 @@ async def handle_viewmode_inputs( if view.hasFocus(): # update mode label - view._chart.window().mode_label.setText('mode: view') + view._chart.window().set_mode_name('view') view.order_mode = False @@ -239,7 +238,7 @@ class ChartView(ViewBox): - zoom on right-click-n-drag to cursor position ''' - mode_name: str = 'mode: view' + mode_name: str = 'view' def __init__( self, From a2b61a67b52c4d4843db9ec7a07f2268ba721ecf Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 24 Jul 2021 16:09:42 -0400 Subject: [PATCH 043/178] Allocate pp config with new actory, drop old line update method --- piker/ui/order_mode.py | 55 +++++++++++++----------------------------- 1 file changed, 17 insertions(+), 38 deletions(-) diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index 6592628e..aa9a01d5 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -35,7 +35,7 @@ from ._editors import LineEditor, ArrowEditor from ._lines import LevelLine from ._position import PositionTracker from ._window import MultiStatus -from ._text_entry import LabeledTextInput +from ._text_entry import FieldsForm, mk_form log = get_logger(__name__) @@ -83,7 +83,7 @@ class OrderMode: arrows: ArrowEditor multistatus: MultiStatus pp: PositionTracker - pp_config: LabeledTextInput + pp_config: FieldsForm name: str = 'order' @@ -95,43 +95,9 @@ class OrderMode: _action: str = 'alert' _exec_mode: str = 'dark' _size: float = 100.0 - _position: Dict[str, Any] = field(default_factory=dict) - # _position_line: dict = None dialogs: dict[str, OrderDialog] = field(default_factory=dict) - # def on_position_update( - # self, - - # size: float, - # price: float, - - # ) -> None: - - # line = self._position_line - - # if line is None and size: - - # # create and show a pp line - # line = self._position_line = position_line( - # self.chart, - # level=price, - # size=size, - # ) - # line.show() - - # elif line: - - # if size != 0.0: - # line.set_level(price) - # line.update_labels({'size': size}) - # line.show() - - # else: - # # remove pp line from view - # line.delete() - # self._position_line = None - def uuid(self) -> str: return str(uuid.uuid4()) @@ -423,7 +389,15 @@ async def run_order_mode( pp.hide() # insert order mode config to left of mode label - pp_config = LabeledTextInput(chart.linked.godwidget) + pp_config = mk_form( + parent=chart.linked.godwidget, + fields={ + '**$size**:': 5000, + '**divisor**:': 4, + # '**policy**:': 'uniform', + # '**type**:': 'live-buy', + }, + ) sb = chart.window().statusBar() sb.insertPermanentWidget(0, pp_config) pp_config.hide() @@ -479,7 +453,12 @@ async def run_order_mode( done() # start async input handling for chart's view - async with chart._vb.open_async_input_handler(): + async with ( + chart._vb.open_async_input_handler(), + + # TODO: config form handler nursery + + ): # signal to top level symbol loading task we're ready # to handle input since the ems connection is ready From 7e2e316cbfe77c7d98d3962a2507e6105b5be904 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 24 Jul 2021 16:11:41 -0400 Subject: [PATCH 044/178] "Forms" is a better module name --- piker/ui/{_text_entry.py => _forms.py} | 0 piker/ui/_search.py | 2 +- piker/ui/order_mode.py | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename piker/ui/{_text_entry.py => _forms.py} (100%) diff --git a/piker/ui/_text_entry.py b/piker/ui/_forms.py similarity index 100% rename from piker/ui/_text_entry.py rename to piker/ui/_forms.py diff --git a/piker/ui/_search.py b/piker/ui/_search.py index 542f9e7b..72a1bfa6 100644 --- a/piker/ui/_search.py +++ b/piker/ui/_search.py @@ -72,7 +72,7 @@ from ._style import ( _font, DpiAwareFont, ) -from ._text_entry import FontAndChartAwareLineEdit +from ._forms import FontAndChartAwareLineEdit log = get_logger(__name__) diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index aa9a01d5..eb208a44 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -35,7 +35,7 @@ from ._editors import LineEditor, ArrowEditor from ._lines import LevelLine from ._position import PositionTracker from ._window import MultiStatus -from ._text_entry import FieldsForm, mk_form +from ._forms import FieldsForm, mk_form log = get_logger(__name__) From c18cf4f0bff4f36ccc204af1dc4e5d1960dc4b25 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 24 Jul 2021 23:15:44 -0400 Subject: [PATCH 045/178] Mock up initial selection field and progress bar --- piker/ui/_forms.py | 81 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 65 insertions(+), 16 deletions(-) diff --git a/piker/ui/_forms.py b/piker/ui/_forms.py index 49fb62b1..2461fd62 100644 --- a/piker/ui/_forms.py +++ b/piker/ui/_forms.py @@ -15,20 +15,25 @@ # along with this program. If not, see . ''' -Text entry widgets (mostly for configuration). +Text entry "forms" widgets (mostly for configuration and UI user input). ''' from typing import Optional # import trio from PyQt5 import QtCore, QtGui -from PyQt5 import QtWidgets -from PyQt5.QtWidgets import QWidget +from PyQt5.QtWidgets import ( + QWidget, + QComboBox, + QLineEdit, + QProgressBar, + QSizePolicy, +) from ._style import hcolor, _font, DpiAwareFont -class FontAndChartAwareLineEdit(QtWidgets.QLineEdit): +class FontAndChartAwareLineEdit(QLineEdit): def __init__( @@ -59,8 +64,8 @@ class FontAndChartAwareLineEdit(QtWidgets.QLineEdit): # size it as we specify # https://doc.qt.io/qt-5/qsizepolicy.html#Policy-enum self.setSizePolicy( - QtWidgets.QSizePolicy.Expanding, - QtWidgets.QSizePolicy.Fixed, + QSizePolicy.Expanding, + QSizePolicy.Fixed, ) self.setFont(font.font) @@ -112,8 +117,8 @@ class FieldsForm(QtGui.QWidget): # size it as we specify self.setSizePolicy( - QtWidgets.QSizePolicy.Fixed, - QtWidgets.QSizePolicy.Fixed, + QSizePolicy.Fixed, + QSizePolicy.Fixed, ) # split layout for the (label:| text bar entry) @@ -121,15 +126,11 @@ class FieldsForm(QtGui.QWidget): self.hbox.setContentsMargins(16, 0, 16, 0) self.hbox.setSpacing(3) - def add_field( + def add_field_label( self, - name: str, - value: str, - widget: Optional[QWidget] = None, - - ) -> None: + ) -> QtGui.QLabel: # add label to left of search bar self.label = label = QtGui.QLabel(parent=self) @@ -140,7 +141,6 @@ class FieldsForm(QtGui.QWidget): ) label.setMargin(4) - # name = "share cap:" label.setText(name) label.setAlignment( @@ -150,6 +150,20 @@ class FieldsForm(QtGui.QWidget): label.show() self.hbox.addWidget(label) + return label + + def add_edit_field( + self, + + name: str, + value: str, + + widget: Optional[QWidget] = None, + + ) -> None: + + # TODO: maybe a distint layout per "field" item? + self.add_field_label(name) self.edit = FontAndChartAwareLineEdit( parent=self, @@ -162,6 +176,31 @@ class FieldsForm(QtGui.QWidget): self.edit.setText(str(value)) self.hbox.addWidget(self.edit) + def add_select_field( + self, + + name: str, + values: list[str], + + ) -> QComboBox: + + # TODO: maybe a distint layout per "field" item? + self.add_field_label(name) + + select = QComboBox(self) + + for i, value in enumerate(values): + select.insertItem(0, str(value)) + + select.setStyleSheet( + f"QComboBox {{ color : {hcolor('gunmetal')}; }}" + ) + select.show() + + self.hbox.addWidget(select) + + return select + # async def handle_form_input( @@ -204,6 +243,16 @@ def mk_form( form = FieldsForm(parent) for name, value in fields.items(): - form.add_field(name, value) + form.add_edit_field(name, value) + + form.add_select_field('policy:', ['uniform', 'halfs']) + + form.add_field_label('fills:') + fill_bar = QProgressBar(form) + fill_bar.setMinimum(0) + fill_bar.setMaximum(4) + fill_bar.setValue(3) + + form.hbox.addWidget(fill_bar) return form From 29ea91553d05ca4bce044e8a8e7931a9001eb51d Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 24 Jul 2021 23:16:22 -0400 Subject: [PATCH 046/178] Use "slots" as name for "number of entries" --- piker/ui/order_mode.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index eb208a44..e255148e 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -393,7 +393,7 @@ async def run_order_mode( parent=chart.linked.godwidget, fields={ '**$size**:': 5000, - '**divisor**:': 4, + '**slots**:': 4, # '**policy**:': 'uniform', # '**type**:': 'live-buy', }, From 940aafe1beef1a7c8a18c0ade594ebd2148a4a7b Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 25 Jul 2021 12:14:03 -0400 Subject: [PATCH 047/178] OMG Qt view item sizing is sooo dumb.. --- piker/ui/_forms.py | 61 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/piker/ui/_forms.py b/piker/ui/_forms.py index 2461fd62..6fe419ad 100644 --- a/piker/ui/_forms.py +++ b/piker/ui/_forms.py @@ -22,12 +22,15 @@ from typing import Optional # import trio from PyQt5 import QtCore, QtGui +from PyQt5.QtCore import QSize, QModelIndex from PyQt5.QtWidgets import ( QWidget, QComboBox, QLineEdit, QProgressBar, QSizePolicy, + QStyledItemDelegate, + QStyleOptionViewItem, ) from ._style import hcolor, _font, DpiAwareFont @@ -72,7 +75,7 @@ class FontAndChartAwareLineEdit(QLineEdit): # witty bit of margin self.setTextMargins(2, 2, 2, 2) - def sizeHint(self) -> QtCore.QSize: + def sizeHint(self) -> QSize: """ Scale edit box to size of dpi aware font. @@ -104,6 +107,39 @@ class FontAndChartAwareLineEdit(QLineEdit): self.setFocus() +class FontScaledDelegate(QStyledItemDelegate): + """ + Super simple view delegate to render text in the same + font size as the search widget. + + """ + + def __init__( + self, + + parent=None, + font: DpiAwareFont = _font, + + ) -> None: + + super().__init__(parent) + self.dpi_font = font + + def sizeHint( + self, + + option: QStyleOptionViewItem, + index: QModelIndex, + + ) -> QSize: + + # value = index.data() + # br = self.dpi_font.boundingRect(value) + # w, h = br.width(), br.height() + w, h = self.parent()._max_item_size + return QSize(w, h) + + class FieldsForm(QtGui.QWidget): def __init__( @@ -190,13 +226,32 @@ class FieldsForm(QtGui.QWidget): select = QComboBox(self) for i, value in enumerate(values): - select.insertItem(0, str(value)) + select.insertItem(i, str(value)) select.setStyleSheet( f"QComboBox {{ color : {hcolor('gunmetal')}; }}" ) - select.show() + select.setSizeAdjustPolicy(QComboBox.AdjustToContents) + select.setIconSize(QSize(0, 0)) + self.setSizePolicy( + QSizePolicy.Fixed, + QSizePolicy.Fixed, + ) + view = select.view() + view.setItemDelegate(FontScaledDelegate(view)) + # compute maximum item size so that the weird + # "style item delegate" thing can then specify + # that size on each item... + values.sort() + br = _font.boundingRect(str(values[-1])) + w, h = br.width(), br.height() + view._max_item_size = w, h + view.setUniformItemSizes(True) + + # limit to 6 items? + view.setMaximumHeight(6*h) + select.show() self.hbox.addWidget(select) return select From 5ec00ee7622847f3831659d0b8ee49d248f0ffe2 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 25 Jul 2021 15:07:02 -0400 Subject: [PATCH 048/178] Size view delegate from monkey patched parent --- piker/ui/_forms.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/piker/ui/_forms.py b/piker/ui/_forms.py index 6fe419ad..f33b25f1 100644 --- a/piker/ui/_forms.py +++ b/piker/ui/_forms.py @@ -108,12 +108,11 @@ class FontAndChartAwareLineEdit(QLineEdit): class FontScaledDelegate(QStyledItemDelegate): - """ + ''' Super simple view delegate to render text in the same font size as the search widget. - """ - + ''' def __init__( self, @@ -136,8 +135,13 @@ class FontScaledDelegate(QStyledItemDelegate): # value = index.data() # br = self.dpi_font.boundingRect(value) # w, h = br.width(), br.height() - w, h = self.parent()._max_item_size - return QSize(w, h) + parent = self.parent() + + if getattr(parent, '_max_item_size', None): + return QSize(*self.parent()._max_item_size) + + else: + return super().sizeHint(option, index) class FieldsForm(QtGui.QWidget): @@ -238,6 +242,7 @@ class FieldsForm(QtGui.QWidget): QSizePolicy.Fixed, ) view = select.view() + view.setUniformItemSizes(True) view.setItemDelegate(FontScaledDelegate(view)) # compute maximum item size so that the weird @@ -246,12 +251,14 @@ class FieldsForm(QtGui.QWidget): values.sort() br = _font.boundingRect(str(values[-1])) w, h = br.width(), br.height() + + # TODO: something better then this monkey patch view._max_item_size = w, h - view.setUniformItemSizes(True) # limit to 6 items? view.setMaximumHeight(6*h) select.show() + self.hbox.addWidget(select) return select From d1244608bd91f3bf1c63d2e5a8028e45249ccae2 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 25 Jul 2021 15:07:26 -0400 Subject: [PATCH 049/178] Use font scaled delegate from forms module --- piker/ui/_search.py | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/piker/ui/_search.py b/piker/ui/_search.py index 72a1bfa6..c8d25f41 100644 --- a/piker/ui/_search.py +++ b/piker/ui/_search.py @@ -63,37 +63,21 @@ from PyQt5.QtWidgets import ( QTreeView, # QListWidgetItem, # QAbstractScrollArea, - QStyledItemDelegate, + # QStyledItemDelegate, ) from ..log import get_logger from ._style import ( _font, - DpiAwareFont, + # DpiAwareFont, ) -from ._forms import FontAndChartAwareLineEdit +from ._forms import FontAndChartAwareLineEdit, FontScaledDelegate log = get_logger(__name__) -class SimpleDelegate(QStyledItemDelegate): - """ - Super simple view delegate to render text in the same - font size as the search widget. - - """ - - def __init__( - self, - parent=None, - font: DpiAwareFont = _font, - ) -> None: - super().__init__(parent) - self.dpi_font = font - - class CompleterView(QTreeView): mode_name: str = 'search-nav' @@ -130,7 +114,7 @@ class CompleterView(QTreeView): self.labels = labels # a std "tabular" config - self.setItemDelegate(SimpleDelegate()) + self.setItemDelegate(FontScaledDelegate(self)) self.setModel(model) self.setAlternatingRowColors(True) # TODO: size this based on DPI font From 43b769a1365f8e78b4aeccad53300268c5ccd686 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 25 Jul 2021 15:42:48 -0400 Subject: [PATCH 050/178] Support opening a handler on a collection of widgets --- piker/ui/_chart.py | 4 ++-- piker/ui/_event.py | 16 +++++++++++----- piker/ui/_interaction.py | 4 ++-- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index a162d76a..78aa631b 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -1689,8 +1689,8 @@ async def _async_main( # start handling search bar kb inputs async with ( - _event.open_handler( - search.bar, + _event.open_handlers( + [search.bar], event_types={QEvent.KeyPress}, async_handler=_search.handle_keyboard_input, # let key repeats pass through for search diff --git a/piker/ui/_event.py b/piker/ui/_event.py index a99b3241..769d96cf 100644 --- a/piker/ui/_event.py +++ b/piker/ui/_event.py @@ -18,7 +18,7 @@ Qt event proxying and processing using ``trio`` mem chans. """ -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, AsyncExitStack from typing import Callable from PyQt5 import QtCore @@ -124,9 +124,9 @@ async def open_event_stream( @asynccontextmanager -async def open_handler( +async def open_handlers( - source_widget: QWidget, + source_widgets: list[QWidget], event_types: set[QEvent], async_handler: Callable[[QWidget, trio.abc.ReceiveChannel], None], **kwargs, @@ -135,7 +135,13 @@ async def open_handler( async with ( trio.open_nursery() as n, - open_event_stream(source_widget, event_types, **kwargs) as event_recv_stream, + AsyncExitStack() as stack, ): - n.start_soon(async_handler, source_widget, event_recv_stream) + for widget in source_widgets: + + event_recv_stream = await stack.enter_async_context( + open_event_stream(widget, event_types, **kwargs) + ) + n.start_soon(async_handler, widget, event_recv_stream) + yield diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 4e5f8017..a14fcb97 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -273,8 +273,8 @@ class ChartView(ViewBox): ) -> 'ChartView': from . import _event - async with _event.open_handler( - self, + async with _event.open_handlers( + [self], event_types={QEvent.KeyPress, QEvent.KeyRelease}, async_handler=handle_viewmode_inputs, ): From 011f36fc3ca1800ae13be440f1c7c5b7f3882306 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 25 Jul 2021 15:43:41 -0400 Subject: [PATCH 051/178] WIP add input handler for each widget in the form --- piker/ui/_forms.py | 92 ++++++++++++++++++++++++++++++---------------- 1 file changed, 60 insertions(+), 32 deletions(-) diff --git a/piker/ui/_forms.py b/piker/ui/_forms.py index f33b25f1..9e03a809 100644 --- a/piker/ui/_forms.py +++ b/piker/ui/_forms.py @@ -18,13 +18,16 @@ Text entry "forms" widgets (mostly for configuration and UI user input). ''' +from functools import partial from typing import Optional +from contextlib import asynccontextmanager -# import trio +import trio from PyQt5 import QtCore, QtGui -from PyQt5.QtCore import QSize, QModelIndex +from PyQt5.QtCore import QSize, QModelIndex, Qt, QEvent from PyQt5.QtWidgets import ( QWidget, + QLabel, QComboBox, QLineEdit, QProgressBar, @@ -33,6 +36,7 @@ from PyQt5.QtWidgets import ( QStyleOptionViewItem, ) +from ._event import open_handlers from ._style import hcolor, _font, DpiAwareFont @@ -144,7 +148,7 @@ class FontScaledDelegate(QStyledItemDelegate): return super().sizeHint(option, index) -class FieldsForm(QtGui.QWidget): +class FieldsForm(QWidget): def __init__( self, @@ -166,6 +170,9 @@ class FieldsForm(QtGui.QWidget): self.hbox.setContentsMargins(16, 0, 16, 0) self.hbox.setSpacing(3) + self.labels: dict[str, QLabel] = {} + self.fields: dict[str, QWidget] = {} + def add_field_label( self, name: str, @@ -190,6 +197,8 @@ class FieldsForm(QtGui.QWidget): label.show() self.hbox.addWidget(label) + self.labels[name] = label + return label def add_edit_field( @@ -200,21 +209,25 @@ class FieldsForm(QtGui.QWidget): widget: Optional[QWidget] = None, - ) -> None: + ) -> FontAndChartAwareLineEdit: # TODO: maybe a distint layout per "field" item? self.add_field_label(name) - self.edit = FontAndChartAwareLineEdit( + edit = FontAndChartAwareLineEdit( parent=self, # parent_chart=self.godwidget, # width_in_chars=6, ) - self.edit.setStyleSheet( + edit.setStyleSheet( f"QLineEdit {{ color : {hcolor('gunmetal')}; }}" ) - self.edit.setText(str(value)) - self.hbox.addWidget(self.edit) + edit.setText(str(value)) + self.hbox.addWidget(edit) + + self.fields[name] = edit + + return edit def add_select_field( self, @@ -264,37 +277,39 @@ class FieldsForm(QtGui.QWidget): return select -# async def handle_form_input( +async def handle_field_input( -# chart: 'ChartPlotWidget', # noqa -# form: FieldsForm, -# recv_chan: trio.abc.ReceiveChannel, + # chart: 'ChartPlotWidget', # noqa + widget: QWidget, + recv_chan: trio.abc.ReceiveChannel, + form: FieldsForm, -# ) -> None: +) -> None: -# async for event, etype, key, mods, txt in recv_chan: -# print(f'key: {key}, mods: {mods}, txt: {txt}') + async for event, etype, key, mods, txt in recv_chan: + print(f'key: {key}, mods: {mods}, txt: {txt}') -# ctl = False -# if mods == Qt.ControlModifier: -# ctl = True + ctl = False + if mods == Qt.ControlModifier: + ctl = True -# # cancel and close -# if ctl and key in { -# Qt.Key_C, -# Qt.Key_Space, # i feel like this is the "native" one -# Qt.Key_Alt, -# }: -# # search.bar.unfocus() + # cancel and close + if ctl and key in { + Qt.Key_C, + Qt.Key_Space, # i feel like this is the "native" one + Qt.Key_Alt, + }: + # search.bar.unfocus() -# # kill the search and focus back on main chart -# if chart: -# chart.linkedsplits.focus() + # kill the search and focus back on main chart + if chart: + chart.linkedsplits.focus() -# continue + continue -def mk_form( +@asynccontextmanager +async def mk_form( parent: QWidget, fields: dict, @@ -307,7 +322,7 @@ def mk_form( for name, value in fields.items(): form.add_edit_field(name, value) - form.add_select_field('policy:', ['uniform', 'halfs']) + form.add_select_field('policy:', ['uniform']) form.add_field_label('fills:') fill_bar = QProgressBar(form) @@ -317,4 +332,17 @@ def mk_form( form.hbox.addWidget(fill_bar) - return form + async with open_handlers( + + list(form.fields.values()), + event_types={QEvent.KeyPress}, + + async_handler=partial( + handle_field_input, + form=form, + ), + + # block key repeats? + filter_auto_repeats=True, + ): + yield form From d022a105bb2357346a1beca0e8694b97e17b33b9 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 25 Jul 2021 20:32:29 -0400 Subject: [PATCH 052/178] Start using a small schema for generating forms --- piker/ui/_forms.py | 16 ++++++++++++---- piker/ui/order_mode.py | 32 ++++++++++++++++++++++---------- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/piker/ui/_forms.py b/piker/ui/_forms.py index 9e03a809..ab155394 100644 --- a/piker/ui/_forms.py +++ b/piker/ui/_forms.py @@ -309,7 +309,7 @@ async def handle_field_input( @asynccontextmanager -async def mk_form( +async def open_form( parent: QWidget, fields: dict, @@ -319,10 +319,18 @@ async def mk_form( form = FieldsForm(parent) - for name, value in fields.items(): - form.add_edit_field(name, value) + for name, config in fields.items(): + wtype = config['type'] + key = str(config['key']) - form.add_select_field('policy:', ['uniform']) + # plain (line) edit field + if wtype == 'edit': + form.add_edit_field(key, config['default_value']) + + # drop-down selection + elif wtype == 'select': + values = list(config['default_value']) + form.add_select_field(key, values) form.add_field_label('fills:') fill_bar = QProgressBar(form) diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index e255148e..30229b82 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -35,7 +35,7 @@ from ._editors import LineEditor, ArrowEditor from ._lines import LevelLine from ._position import PositionTracker from ._window import MultiStatus -from ._forms import FieldsForm, mk_form +from ._forms import FieldsForm, open_form log = get_logger(__name__) @@ -378,6 +378,27 @@ async def run_order_mode( positions ), + open_form( + parent=chart.linked.godwidget, + fields={ + 'dollar_size': { + 'key': '**$size**:', + 'type': 'edit', + 'default_value': 5000, + }, + 'slots': { + 'key': '**slots**:', + 'type': 'edit', + 'default_value': 4, + }, + 'disti_policy': { + 'key': '**policy**:', + 'type': 'select', + 'default_value': ['uniform'], + }, + }, + ) as pp_config, + ): view = chart._vb lines = LineEditor(chart=chart) @@ -389,15 +410,6 @@ async def run_order_mode( pp.hide() # insert order mode config to left of mode label - pp_config = mk_form( - parent=chart.linked.godwidget, - fields={ - '**$size**:': 5000, - '**slots**:': 4, - # '**policy**:': 'uniform', - # '**type**:': 'live-buy', - }, - ) sb = chart.window().statusBar() sb.insertPermanentWidget(0, pp_config) pp_config.hide() From 1ae39c963aec214820c894eb6bd4463eb0040e2c Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 26 Jul 2021 11:31:36 -0400 Subject: [PATCH 053/178] Allocate pp config form alongside god widget as a side-pane --- piker/ui/_chart.py | 70 ++++++++++++++++++++++++++++++++++++++---- piker/ui/order_mode.py | 34 +++----------------- 2 files changed, 69 insertions(+), 35 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 78aa631b..6109ef7f 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -68,6 +68,7 @@ from ._interaction import ChartView from .order_mode import run_order_mode from .. import fsp from ..data import feed +from ._forms import FieldsForm, open_form log = get_logger(__name__) @@ -94,11 +95,13 @@ class GodWidget(QtWidgets.QWidget): self.hbox = QtWidgets.QHBoxLayout(self) self.hbox.setContentsMargins(0, 0, 0, 0) - self.hbox.setSpacing(2) + self.hbox.setSpacing(6) + self.hbox.setAlignment(Qt.AlignTop) self.vbox = QtWidgets.QVBoxLayout() self.vbox.setContentsMargins(0, 0, 0, 0) self.vbox.setSpacing(2) + self.vbox.setAlignment(Qt.AlignTop) self.hbox.addLayout(self.vbox) @@ -390,7 +393,6 @@ class LinkedSplits(QtWidgets.QWidget): 'left': PriceAxis(linkedsplits=self, orientation='left'), }, viewBox=cv, - # cursor=self.cursor, **cpw_kwargs, ) print(f'xaxis ps: {xaxis.pos()}') @@ -472,7 +474,6 @@ class ChartPlotWidget(pg.PlotWidget): pen_color: str = 'bracket', static_yrange: Optional[Tuple[float, float]] = None, - cursor: Optional[Cursor] = None, **kwargs, ): @@ -1576,7 +1577,12 @@ async def display_symbol_data( linkedsplits ) - await run_order_mode(chart, symbol, provider, order_mode_started) + await run_order_mode( + chart, + symbol, + provider, + order_mode_started + ) async def load_provider_search( @@ -1658,7 +1664,7 @@ async def _async_main( # alights to top and uses minmial space based on # search bar size hint (i think?) - alignment=Qt.AlignTop + # alignment=Qt.AlignTop ) godwidget.search = search @@ -1695,8 +1701,60 @@ async def _async_main( async_handler=_search.handle_keyboard_input, # let key repeats pass through for search filter_auto_repeats=False, - ) + ), + + open_form( + godwidget=godwidget, + parent=godwidget, + fields={ + 'account': { + 'key': '**account**:', + 'type': 'select', + 'default_value': ['margin'], + }, + 'disti_policy': { + 'key': '**policy**:', + 'type': 'select', + 'default_value': ['uniform'], + }, + 'slots': { + 'key': '**slots**:', + 'type': 'edit', + 'default_value': 4, + }, + 'allocator': { + 'key': '**allocator**:', + 'type': 'select', + 'default_value': ['$ size', '% of port',], + }, + 'dollar_size': { + 'key': '**$size**:', + 'type': 'edit', + 'default_value': '5k', + }, + }, + ) as pp_config, ): + pp_config: FieldsForm + pp_config.show() + + # add as next-to-y-axis pane + godwidget.pp_config = pp_config + + godwidget.hbox.insertWidget( + 1, + pp_config, + + # alights to top and uses minmial space based on + # search bar size hint (i think?) + alignment=Qt.AlignTop + ) + + godwidget.hbox.setAlignment(Qt.AlignTop) + # sb = god_widget.window.statusBar() + # sb.insertPermanentWidget(0, pp_config) + # pp_config.show() + # remove startup status text starting_done() await trio.sleep_forever() diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index 30229b82..7ca26e5d 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -35,7 +35,7 @@ from ._editors import LineEditor, ArrowEditor from ._lines import LevelLine from ._position import PositionTracker from ._window import MultiStatus -from ._forms import FieldsForm, open_form +from ._forms import FieldsForm log = get_logger(__name__) @@ -83,7 +83,6 @@ class OrderMode: arrows: ArrowEditor multistatus: MultiStatus pp: PositionTracker - pp_config: FieldsForm name: str = 'order' @@ -101,6 +100,10 @@ class OrderMode: def uuid(self) -> str: return str(uuid.uuid4()) + @property + def pp_config(self) -> FieldsForm: + return self.chart.linked.godwidget.pp_config + def set_exec( self, action: str, @@ -378,27 +381,6 @@ async def run_order_mode( positions ), - open_form( - parent=chart.linked.godwidget, - fields={ - 'dollar_size': { - 'key': '**$size**:', - 'type': 'edit', - 'default_value': 5000, - }, - 'slots': { - 'key': '**slots**:', - 'type': 'edit', - 'default_value': 4, - }, - 'disti_policy': { - 'key': '**policy**:', - 'type': 'select', - 'default_value': ['uniform'], - }, - }, - ) as pp_config, - ): view = chart._vb lines = LineEditor(chart=chart) @@ -409,11 +391,6 @@ async def run_order_mode( pp = PositionTracker(chart) pp.hide() - # insert order mode config to left of mode label - sb = chart.window().statusBar() - sb.insertPermanentWidget(0, pp_config) - pp_config.hide() - mode = OrderMode( chart, book, @@ -421,7 +398,6 @@ async def run_order_mode( arrows, multistatus, pp, - pp_config, ) # so that view handlers can access it From 0ce356f5d97974ab72ca1cf38b89aba7bca25a44 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 26 Jul 2021 11:32:54 -0400 Subject: [PATCH 054/178] Make field form a vertical layout, add formatted style sheets --- piker/ui/_forms.py | 80 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 63 insertions(+), 17 deletions(-) diff --git a/piker/ui/_forms.py b/piker/ui/_forms.py index ab155394..c3259de7 100644 --- a/piker/ui/_forms.py +++ b/piker/ui/_forms.py @@ -37,7 +37,7 @@ from PyQt5.QtWidgets import ( ) from ._event import open_handlers -from ._style import hcolor, _font, DpiAwareFont +from ._style import hcolor, _font, _font_small, DpiAwareFont class FontAndChartAwareLineEdit(QLineEdit): @@ -153,38 +153,54 @@ class FieldsForm(QWidget): def __init__( self, - # godwidget: 'GodWidget', # type: ignore # noqa + godwidget: 'GodWidget', # type: ignore # noqa parent=None, ) -> None: - super().__init__(parent) + + super().__init__(parent or godwidget) + self.godwidget = godwidget # size it as we specify self.setSizePolicy( QSizePolicy.Fixed, QSizePolicy.Fixed, ) + # self.setMaximumHeight(30) + self.setMaximumWidth(120) # split layout for the (label:| text bar entry) - self.hbox = QtGui.QHBoxLayout(self) - self.hbox.setContentsMargins(16, 0, 16, 0) - self.hbox.setSpacing(3) + self.vbox = QtGui.QVBoxLayout(self) + self.vbox.setAlignment(Qt.AlignBottom) + self.vbox.setContentsMargins(0, 0, 4, 0) + self.vbox.setSpacing(3) + # self.vbox.addStretch() self.labels: dict[str, QLabel] = {} self.fields: dict[str, QWidget] = {} + self._font_size = _font_small.px_size - 1 + self._max_item_width: (float, float) = 0, 0 + def add_field_label( self, name: str, + font_size: Optional[int] = None, + font_color: str = 'default_light', ) -> QtGui.QLabel: # add label to left of search bar self.label = label = QtGui.QLabel(parent=self) - label.setTextFormat(3) # markdown + label.setTextFormat(Qt.MarkdownText) # markdown label.setFont(_font.font) + font_size = font_size or self._font_size - 3 label.setStyleSheet( - f"QLabel {{ color : {hcolor('papas_special')}; }}" + f"""QLabel {{ + color : {hcolor(font_color)}; + font-size : {font_size}px; + }} + """ ) label.setMargin(4) @@ -196,7 +212,7 @@ class FieldsForm(QWidget): ) label.show() - self.hbox.addWidget(label) + self.vbox.addWidget(label) self.labels[name] = label return label @@ -220,10 +236,14 @@ class FieldsForm(QWidget): # width_in_chars=6, ) edit.setStyleSheet( - f"QLineEdit {{ color : {hcolor('gunmetal')}; }}" + f"""QLineEdit {{ + color : {hcolor('gunmetal')}; + font-size : {self._font_size}px; + }} + """ ) edit.setText(str(value)) - self.hbox.addWidget(edit) + self.vbox.addWidget(edit) self.fields[name] = edit @@ -246,7 +266,11 @@ class FieldsForm(QWidget): select.insertItem(i, str(value)) select.setStyleSheet( - f"QComboBox {{ color : {hcolor('gunmetal')}; }}" + f"""QComboBox {{ + color : {hcolor('gunmetal')}; + font-size : {self._font_size}px; + }} + """ ) select.setSizeAdjustPolicy(QComboBox.AdjustToContents) select.setIconSize(QSize(0, 0)) @@ -270,9 +294,12 @@ class FieldsForm(QWidget): # limit to 6 items? view.setMaximumHeight(6*h) + # one entry in view + select.setMinimumHeight(h) + select.show() - self.hbox.addWidget(select) + self.vbox.addWidget(select) return select @@ -302,8 +329,8 @@ async def handle_field_input( # search.bar.unfocus() # kill the search and focus back on main chart - if chart: - chart.linkedsplits.focus() + widget.clearFocus() + form.godwidget.linkedsplits.focus() continue @@ -311,6 +338,7 @@ async def handle_field_input( @asynccontextmanager async def open_form( + godwidget: QWidget, parent: QWidget, fields: dict, # orientation: str = 'horizontal', @@ -319,6 +347,12 @@ async def open_form( form = FieldsForm(parent) + # form.add_field_label( + # '### **pp conf**\n---', + # font_size=_font.px_size - 2, + # font_color='default_lightest', + # ) + for name, config in fields.items(): wtype = config['type'] key = str(config['key']) @@ -332,13 +366,25 @@ async def open_form( values = list(config['default_value']) form.add_select_field(key, values) - form.add_field_label('fills:') + # form.add_field_label('fills:') fill_bar = QProgressBar(form) fill_bar.setMinimum(0) fill_bar.setMaximum(4) fill_bar.setValue(3) + fill_bar.setOrientation(Qt.Vertical) + fill_bar.setStyleSheet( + f"""QProgressBar {{ + color : {hcolor('gunmetal')}; + font-size : {form._font_size}px; + background-color: {hcolor('papas_special')}; + color: {hcolor('bracket')}; + }} + """ + ) - form.hbox.addWidget(fill_bar) + form.vbox.addWidget(fill_bar) + + # form.vbox.addStretch() async with open_handlers( From 30ac32da55669d0cc4054a95a9410ac6a23a6984 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 26 Jul 2021 11:33:14 -0400 Subject: [PATCH 055/178] Add ctrl-p as "pane toggle" --- piker/ui/_interaction.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index a14fcb97..7e80f197 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -162,9 +162,20 @@ async def handle_viewmode_inputs( else: view.setMouseMode(ViewBox.PanMode) + # Toggle position config pane + if ( + ctrl and key in { + Qt.Key_P, + } + ): + pp_conf = mode.pp_config + if pp_conf.isHidden(): + pp_conf.show() + else: + pp_conf.hide() + # ORDER MODE # # live vs. dark trigger + an action {buy, sell, alert} - order_keys_pressed = { Qt.Key_A, Qt.Key_F, @@ -175,8 +186,9 @@ async def handle_viewmode_inputs( # show the pp label mode.pp.show() - # show pp config params in status bar widget - mode.pp_config.show() + + # TODO: show pp config mini-params in status bar widget + # mode.pp_config.show() if ( # 's' for "submit" to activate "live" order @@ -212,7 +224,7 @@ async def handle_viewmode_inputs( # hide pp label mode.pp.hide_info() - mode.pp_config.hide() + # mode.pp_config.hide() # if none are pressed, remove "staged" level # line under cursor position From 21d1e17c6a5746ff20dae36d4e2650520bde90bc Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 26 Jul 2021 11:33:37 -0400 Subject: [PATCH 056/178] Better search label styling --- piker/ui/_search.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/piker/ui/_search.py b/piker/ui/_search.py index c8d25f41..fb0ef0f5 100644 --- a/piker/ui/_search.py +++ b/piker/ui/_search.py @@ -45,7 +45,7 @@ import time from fuzzywuzzy import process as fuzzy import trio from trio_typing import TaskStatus -from PyQt5 import QtCore, QtGui +from PyQt5 import QtCore from PyQt5 import QtWidgets from PyQt5.QtCore import ( Qt, @@ -70,7 +70,7 @@ from PyQt5.QtWidgets import ( from ..log import get_logger from ._style import ( _font, - # DpiAwareFont, + hcolor, ) from ._forms import FontAndChartAwareLineEdit, FontScaledDelegate @@ -475,10 +475,17 @@ class SearchWidget(QtWidgets.QWidget): # add label to left of search bar self.label = label = QtWidgets.QLabel(parent=self) + label.setStyleSheet( + f"""QLabel {{ + color : {hcolor('default_lightest')}; + font-size : {_font.px_size - 2}px; + }} + """ + ) label.setTextFormat(3) # markdown label.setFont(_font.font) label.setMargin(4) - label.setText("`search`:") + label.setText("search:") label.show() label.setAlignment( QtCore.Qt.AlignVCenter @@ -845,6 +852,7 @@ async def handle_keyboard_input( # kill the search and focus back on main chart if chart: + print('focussing view') chart.linkedsplits.focus() continue From 3eabe93d54bc7d7c28c195da109e391c0626a739 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 26 Jul 2021 15:35:06 -0400 Subject: [PATCH 057/178] Always hide contents labels at startup --- piker/ui/_chart.py | 14 ++++++-------- piker/ui/_cursor.py | 2 +- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 6109ef7f..3d910df9 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -1281,12 +1281,6 @@ async def run_fsp( # static_yrange=(0, 100), ) - # display contents labels asap - chart.linked.cursor.contents_labels.update_labels( - len(shm.array) - 1, - # fsp_func_name - ) - # XXX: ONLY for sub-chart fsps, overlays have their # data looked up from the chart's internal array set. # TODO: we must get a data view api going STAT!! @@ -1710,10 +1704,14 @@ async def _async_main( 'account': { 'key': '**account**:', 'type': 'select', - 'default_value': ['margin'], + 'default_value': [ + 'piker.paper', + # 'ib.margin', + # 'ib.paper', + ], }, 'disti_policy': { - 'key': '**policy**:', + 'key': '**entry policy**:', 'type': 'select', 'default_value': ['uniform'], }, diff --git a/piker/ui/_cursor.py b/piker/ui/_cursor.py index 7415005a..8d5ba831 100644 --- a/piker/ui/_cursor.py +++ b/piker/ui/_cursor.py @@ -278,7 +278,7 @@ class ContentsLabels: self._labels.append( (chart, name, label, partial(update_func, label, name)) ) - # label.hide() + label.hide() return label From 65158b8c64a0eafd06eb9b380b4731d53dcadf99 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 26 Jul 2021 15:38:15 -0400 Subject: [PATCH 058/178] Add position status (health) bar math for sizing and styling --- piker/ui/_forms.py | 92 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 69 insertions(+), 23 deletions(-) diff --git a/piker/ui/_forms.py b/piker/ui/_forms.py index c3259de7..92dfb0f1 100644 --- a/piker/ui/_forms.py +++ b/piker/ui/_forms.py @@ -167,11 +167,11 @@ class FieldsForm(QWidget): QSizePolicy.Fixed, ) # self.setMaximumHeight(30) - self.setMaximumWidth(120) + self.setMaximumWidth(166) # split layout for the (label:| text bar entry) self.vbox = QtGui.QVBoxLayout(self) - self.vbox.setAlignment(Qt.AlignBottom) + self.vbox.setAlignment(Qt.AlignTop) self.vbox.setContentsMargins(0, 0, 4, 0) self.vbox.setSpacing(3) # self.vbox.addStretch() @@ -179,14 +179,14 @@ class FieldsForm(QWidget): self.labels: dict[str, QLabel] = {} self.fields: dict[str, QWidget] = {} - self._font_size = _font_small.px_size - 1 + self._font_size = _font_small.px_size - 2 self._max_item_width: (float, float) = 0, 0 def add_field_label( self, name: str, font_size: Optional[int] = None, - font_color: str = 'default_light', + font_color: str = 'default_lightest', ) -> QtGui.QLabel: @@ -347,12 +347,6 @@ async def open_form( form = FieldsForm(parent) - # form.add_field_label( - # '### **pp conf**\n---', - # font_size=_font.px_size - 2, - # font_color='default_lightest', - # ) - for name, config in fields.items(): wtype = config['type'] key = str(config['key']) @@ -366,23 +360,75 @@ async def open_form( values = list(config['default_value']) form.add_select_field(key, values) - # form.add_field_label('fills:') + form.vbox.addSpacing(6) + form.add_field_label('fill status') + form.vbox.addSpacing(6) + fill_bar = QProgressBar(form) - fill_bar.setMinimum(0) - fill_bar.setMaximum(4) - fill_bar.setValue(3) + import math + slots = 4 + border_size_px = 2 + slot_margin_px = 2 #1.375 + h = 150 #+ (2*2 + slot_margin_px*slots*2) + # multiples, r = divmod(h, slots) + slot_height_px = math.floor((h - 2*border_size_px)/slots) - slot_margin_px*1 + fill_bar.setOrientation(Qt.Vertical) fill_bar.setStyleSheet( - f"""QProgressBar {{ - color : {hcolor('gunmetal')}; - font-size : {form._font_size}px; - background-color: {hcolor('papas_special')}; - color: {hcolor('bracket')}; - }} - """ - ) + f""" + QProgressBar {{ - form.vbox.addWidget(fill_bar) + text-align: center; + + font-size : {form._font_size - 2}px; + + background-color: {hcolor('papas_special')}; + color : {hcolor('papas_special')}; + + border: {border_size_px}px solid {hcolor('default_light')}; + border-radius: 2px; + }} + + QProgressBar::chunk {{ + + background-color: {hcolor('default_lightest')}; + color: {hcolor('papas_special')}; + + border-radius: 2px; + + margin: {slot_margin_px}px; + height: {slot_height_px}px; + + }} + + """ + # margin-bottom: {slot_margin_px*2}px; + # margin-top: {slot_margin_px*2}px; + # color: #19232D; + # QProgressBar::chunk:disabled {{ + # background-color: #26486B; + # color: #9DA9B5; + # border-radius: 4px; + # height: 20px; + # }} + # margin-top: 3px; + # margin-bottom: 3px; + # width: 10px; + # color: #E0E1E3; + # background-color: #19232D; + + # color : {hcolor('gunmetal')}; + # background-color: {hcolor('bracket')}; + # color: {hcolor('bracket')}; + ) + fill_bar.setRange(0, slots) + fill_bar.setValue(slots) + fill_bar.setFormat('') + fill_bar.setMinimumHeight(h) + fill_bar.setMaximumHeight(h + slots*slot_margin_px) + fill_bar.setMinimumWidth(36) + + form.vbox.addWidget(fill_bar, alignment=Qt.AlignCenter) # form.vbox.addStretch() From 825680b8c63edef11386b2b93b8ce690007fd6b5 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 26 Jul 2021 19:40:39 -0400 Subject: [PATCH 059/178] Support (sub)plot names separate from data array keys --- piker/ui/_chart.py | 129 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 97 insertions(+), 32 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 3d910df9..e0a69bd3 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -299,13 +299,17 @@ class LinkedSplits(QtWidgets.QWidget): def set_split_sizes( self, prop: float = 0.375 # proportion allocated to consumer subcharts + ) -> None: - """Set the proportion of space allocated for linked subcharts. - """ + '''Set the proportion of space allocated for linked subcharts. + + ''' major = 1 - prop min_h_ind = int((self.height() * prop) / len(self.subplots)) + sizes = [int(self.height() * major)] sizes.extend([min_h_ind] * len(self.subplots)) + self.splitter.setSizes(sizes) # , int(self.height()*0.2) def focus(self) -> None: @@ -318,9 +322,12 @@ class LinkedSplits(QtWidgets.QWidget): def plot_ohlc_main( self, + symbol: Symbol, array: np.ndarray, + style: str = 'bar', + ) -> 'ChartPlotWidget': """Start up and show main (price) chart and all linked subcharts. @@ -352,17 +359,23 @@ class LinkedSplits(QtWidgets.QWidget): def add_plot( self, + name: str, array: np.ndarray, - xaxis: DynamicDateAxis = None, + + array_key: Optional[str] = None, + # xaxis: Optional[DynamicDateAxis] = None, style: str = 'line', _is_main: bool = False, + **cpw_kwargs, + ) -> 'ChartPlotWidget': - """Add (sub)plots to chart widget by name. + '''Add (sub)plots to chart widget by name. If ``name`` == ``"main"`` the chart will be the the primary view. - """ + + ''' if self.chart is None and not _is_main: raise RuntimeError( "A main plot must be created first with `.plot_ohlc_main()`") @@ -372,17 +385,25 @@ class LinkedSplits(QtWidgets.QWidget): cv.linkedsplits = self # use "indicator axis" by default - if xaxis is None: - xaxis = DynamicDateAxis( - orientation='bottom', - linkedsplits=self - ) + + # TODO: we gotta possibly assign this back + # to the last subplot on removal of some last subplot + + xaxis = DynamicDateAxis( + orientation='bottom', + linkedsplits=self + ) + + if self.xaxis: + self.xaxis.hide() + self.xaxis = xaxis cpw = ChartPlotWidget( # this name will be used to register the primary # graphics curve managed by the subchart name=name, + data_key=array_key or name, array=array, parent=self.splitter, @@ -395,7 +416,6 @@ class LinkedSplits(QtWidgets.QWidget): viewBox=cv, **cpw_kwargs, ) - print(f'xaxis ps: {xaxis.pos()}') # give viewbox as reference to chart # allowing for kb controls and interactions on **this** widget @@ -416,10 +436,10 @@ class LinkedSplits(QtWidgets.QWidget): # draw curve graphics if style == 'bar': - cpw.draw_ohlc(name, array) + cpw.draw_ohlc(name, array, array_key=array_key) elif style == 'line': - cpw.draw_curve(name, array) + cpw.draw_curve(name, array, array_key=array_key) else: raise ValueError(f"Chart style {style} is currently unsupported") @@ -428,11 +448,12 @@ class LinkedSplits(QtWidgets.QWidget): # track by name self.subplots[name] = cpw + # XXX: we need this right? + self.splitter.addWidget(cpw) + # scale split regions self.set_split_sizes() - # XXX: we need this right? - # self.splitter.addWidget(cpw) else: assert style == 'bar', 'main chart must be OHLC' @@ -465,9 +486,11 @@ class ChartPlotWidget(pg.PlotWidget): def __init__( self, - # the data view we generate graphics from + + # the "data view" we generate graphics from name: str, array: np.ndarray, + data_key: str, linkedsplits: LinkedSplits, view_color: str = 'papas_special', @@ -491,6 +514,7 @@ class ChartPlotWidget(pg.PlotWidget): **kwargs ) self.name = name + self.data_key = data_key self.linked = linkedsplits # scene-local placeholder for book graphics @@ -618,8 +642,12 @@ class ChartPlotWidget(pg.PlotWidget): def draw_ohlc( self, + name: str, data: np.ndarray, + + array_key: Optional[str] = None, + ) -> pg.GraphicsObject: """ Draw OHLC datums to chart. @@ -637,7 +665,8 @@ class ChartPlotWidget(pg.PlotWidget): # draw after to allow self.scene() to work... graphics.draw_from_data(data) - self._graphics[name] = graphics + data_key = array_key or name + self._graphics[data_key] = graphics self.linked.cursor.contents_labels.add_label( self, @@ -652,12 +681,17 @@ class ChartPlotWidget(pg.PlotWidget): def draw_curve( self, + name: str, data: np.ndarray, + + array_key: Optional[str] = None, overlay: bool = False, color: str = 'default_light', add_label: bool = True, + **pdi_kwargs, + ) -> pg.PlotDataItem: """Draw a "curve" (line plot graphics) for the provided data in the input array ``data``. @@ -668,10 +702,11 @@ class ChartPlotWidget(pg.PlotWidget): } pdi_kwargs.update(_pdi_defaults) + data_key = array_key or name # curve = pg.PlotDataItem( # curve = pg.PlotCurveItem( curve = FastAppendCurve( - y=data[name], + y=data[data_key], x=data['index'], # antialias=True, name=name, @@ -730,8 +765,10 @@ class ChartPlotWidget(pg.PlotWidget): def _add_sticky( self, + name: str, bg_color='bracket', + ) -> YAxisLabel: # if the sticky is for our symbol @@ -769,18 +806,23 @@ class ChartPlotWidget(pg.PlotWidget): def update_curve_from_array( self, + name: str, array: np.ndarray, + array_key: Optional[str] = None, + **kwargs, + ) -> pg.GraphicsObject: """Update the named internal graphics from ``array``. """ + data_key = array_key or name if name not in self._overlays: self._arrays['ohlc'] = array else: - self._arrays[name] = array + self._arrays[data_key] = array curve = self._graphics[name] @@ -790,7 +832,7 @@ class ChartPlotWidget(pg.PlotWidget): # one place to dig around this might be the `QBackingStore` # https://doc.qt.io/qt-5/qbackingstore.html # curve.setData(y=array[name], x=array['index'], **kwargs) - curve.update_from_array(x=array['index'], y=array[name], **kwargs) + curve.update_from_array(x=array['index'], y=array[data_key], **kwargs) return curve @@ -1061,8 +1103,7 @@ async def chart_from_quotes( if wap_in_history: # update vwap overlay line - chart.update_curve_from_array( - 'bar_wap', ohlcv.array) + chart.update_curve_from_array('bar_wap', ohlcv.array) # l1 book events # throttle the book graphics updates at a lower rate @@ -1167,9 +1208,9 @@ async def spawn_fsps( # Currently we spawn an actor per fsp chain but # likely we'll want to pool them eventually to # scale horizonatlly once cores are used up. - for fsp_func_name, conf in fsps.items(): + for display_name, conf in fsps.items(): - display_name = f'fsp.{fsp_func_name}' + fsp_func_name = conf['fsp_func_name'] # TODO: load function here and introspect # return stream type(s) @@ -1177,7 +1218,7 @@ async def spawn_fsps( # TODO: should `index` be a required internal field? fsp_dtype = np.dtype([('index', int), (fsp_func_name, float)]) - key = f'{sym}.' + display_name + key = f'{sym}.fsp.' + display_name # this is all sync currently shm, opened = maybe_open_shm_array( @@ -1195,7 +1236,7 @@ async def spawn_fsps( portal = await n.start_actor( enable_modules=['piker.fsp'], - name=display_name, + name='fsp.' + display_name, ) # init async @@ -1234,7 +1275,7 @@ async def run_fsp( config map. """ done = linkedsplits.window().status_bar.open_status( - f'loading {display_name}..', + f'loading fsp, {display_name}..', group_key=group_status_key, ) @@ -1270,9 +1311,11 @@ async def run_fsp( else: chart = linkedsplits.add_plot( - name=fsp_func_name, + name=display_name, array=shm.array, + array_key=conf['fsp_func_name'], + # curve by default ohlc=False, @@ -1294,14 +1337,19 @@ async def run_fsp( # read from last calculated value array = shm.array + + # XXX: fsp func names are unique meaning we don't have + # duplicates of the underlying data even if multiple + # sub-charts reference it under different 'named charts'. value = array[fsp_func_name][-1] + last_val_sticky.update_from_data(-1, value) chart.linked.focus() # works also for overlays in which case data is looked up from # internal chart array set.... - chart.update_curve_from_array(fsp_func_name, shm.array) + chart.update_curve_from_array(display_name, shm.array, array_key=fsp_func_name) # TODO: figure out if we can roll our own `FillToThreshold` to # get brush filled polygons for OS/OB conditions. @@ -1365,7 +1413,11 @@ async def run_fsp( last_val_sticky.update_from_data(-1, value) # update graphics - chart.update_curve_from_array(fsp_func_name, array) + chart.update_curve_from_array( + display_name, + array, + array_key=fsp_func_name, + ) # set time of last graphics update last = now @@ -1420,7 +1472,11 @@ async def check_for_new_bars(feed, ohlcv, linkedsplits): ) for name, chart in linkedsplits.subplots.items(): - chart.update_curve_from_array(chart.name, chart._shm.array) + chart.update_curve_from_array( + chart.name, + chart._shm.array, + array_key=chart.data_key + ) # shift the view if in follow mode price_chart.increment_view() @@ -1510,6 +1566,14 @@ async def display_symbol_data( # TODO: eventually we'll support some kind of n-compose syntax fsp_conf = { 'rsi': { + 'fsp_func_name': 'rsi', + 'period': 14, + 'chart_kwargs': { + 'static_yrange': (0, 100), + }, + }, + 'rsi2': { + 'fsp_func_name': 'rsi', 'period': 14, 'chart_kwargs': { 'static_yrange': (0, 100), @@ -1532,6 +1596,7 @@ async def display_symbol_data( else: fsp_conf.update({ 'vwap': { + 'fsp_func_name': 'vwap', 'overlay': True, 'anchor': 'session', }, @@ -1723,7 +1788,7 @@ async def _async_main( 'allocator': { 'key': '**allocator**:', 'type': 'select', - 'default_value': ['$ size', '% of port',], + 'default_value': ['$ size', '% of port', '# shares'], }, 'dollar_size': { 'key': '**$size**:', From 63138ccbf4a4f53a87711a1261931d79b30df6ef Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 27 Jul 2021 06:09:40 -0400 Subject: [PATCH 060/178] WIP add a lambda-QFrame to get per chart sidpanes for each linkedsplits row --- piker/ui/_chart.py | 275 ++++++++++++++++++++++++++++++++------------- 1 file changed, 197 insertions(+), 78 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index e0a69bd3..9bb0a041 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -26,6 +26,11 @@ from functools import partial from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtCore import Qt from PyQt5.QtCore import QEvent +from PyQt5.QtWidgets import ( + QFrame, + QWidget, + # QSizePolicy, +) import numpy as np import pyqtgraph as pg import tractor @@ -68,13 +73,13 @@ from ._interaction import ChartView from .order_mode import run_order_mode from .. import fsp from ..data import feed -from ._forms import FieldsForm, open_form +from ._forms import FieldsForm, open_form, mk_health_bar log = get_logger(__name__) -class GodWidget(QtWidgets.QWidget): +class GodWidget(QWidget): ''' "Our lord and savior, the holy child of window-shua, there is no widget above thee." - 6|6 @@ -236,7 +241,7 @@ class GodWidget(QtWidgets.QWidget): return order_mode_started -class LinkedSplits(QtWidgets.QWidget): +class LinkedSplits(QWidget): ''' Widget that holds a central chart plus derived subcharts computed from the original data set apart @@ -338,12 +343,16 @@ class LinkedSplits(QtWidgets.QWidget): linkedsplits=self, digits=symbol.digits(), ) + self.chart = self.add_plot( + name=symbol.key, array=array, # xaxis=self.xaxis, style=style, _is_main=True, + + sidepane=self.godwidget.pp_config, ) # add crosshair graphic self.chart.addItem(self.cursor) @@ -353,7 +362,10 @@ class LinkedSplits(QtWidgets.QWidget): self.chart.hideAxis('bottom') # style? - self.chart.setFrameStyle(QtWidgets.QFrame.StyledPanel | QtWidgets.QFrame.Plain) + self.chart.setFrameStyle( + QtWidgets.QFrame.StyledPanel | + QtWidgets.QFrame.Plain + ) return self.chart @@ -368,6 +380,8 @@ class LinkedSplits(QtWidgets.QWidget): style: str = 'line', _is_main: bool = False, + sidepane: Optional[QWidget] = None, + **cpw_kwargs, ) -> 'ChartPlotWidget': @@ -398,6 +412,53 @@ class LinkedSplits(QtWidgets.QWidget): self.xaxis.hide() self.xaxis = xaxis + if sidepane: + + class ChartandPane(QFrame): + + def __init__( + self, + parent=None, + + ) -> None: + + super().__init__(parent) + + # self.chart = cpw + self.sidepane = sidepane + # cpw.chart_and_pane = self + # self.setStyleSheet( + # f"""QFrame {{ + # color : {hcolor('default_darkest')}; + # background-color : {hcolor('default_darkest')}; + # }} + # """ + # ) + + hbox = self.hbox = QtGui.QHBoxLayout(self) + hbox.setAlignment(Qt.AlignTop | Qt.AlignLeft) + hbox.setContentsMargins(0, 0, 0, 0) + hbox.setSpacing(3) + + def sizeHint(self) -> QtCore.QSize: + return self.chart.sizeHint() + sidepane.sizeHint() + + paned_chart = ChartandPane(parent=self.splitter) + + # splitter_widget = QWidget(self) + # splitter_widget.setSizePolicy( + # QSizePolicy.Expanding, + # QSizePolicy.Expanding, + # ) + + # hbox = QtGui.QHBoxLayout(splitter_widget) + # hbox.setAlignment(Qt.AlignTop | Qt.AlignRight) + # hbox.setContentsMargins(0, 0, 0, 0) + # hbox.setSpacing(3) + + # else: + # splitter_widget = cpw + cpw = ChartPlotWidget( # this name will be used to register the primary @@ -406,7 +467,8 @@ class LinkedSplits(QtWidgets.QWidget): data_key=array_key or name, array=array, - parent=self.splitter, + # parent=self.splitter, + parent=paned_chart if sidepane else self.splitter, linkedsplits=self, axisItems={ 'bottom': xaxis, @@ -417,14 +479,38 @@ class LinkedSplits(QtWidgets.QWidget): **cpw_kwargs, ) + if sidepane: + paned_chart.chart = cpw + paned_chart.hbox.addWidget(cpw) + # hbox.addWidget(cpw) + paned_chart.hbox.addWidget( + sidepane, + alignment=Qt.AlignTop + ) + cpw.sidepane = sidepane + + # splitter_widget.setMinimumHeight(cpw.height()) + # splitter_widget.setMinimumWidth(cpw.width()) + # splitter_widget.show() + + # hbox.addWidget(cpw) + # hbox.addWidget(sidepane) + + # cpw.sidepane = sidepane + # cpw.hbox = hbox + # give viewbox as reference to chart # allowing for kb controls and interactions on **this** widget # (see our custom view mode in `._interactions.py`) cv.chart = cpw cpw.plotItem.vb.linkedsplits = self - cpw.setFrameStyle(QtWidgets.QFrame.StyledPanel) # | QtWidgets.QFrame.Plain) + cpw.setFrameStyle( + QtWidgets.QFrame.StyledPanel + # | QtWidgets.QFrame.Plain) + ) cpw.hideButtons() + # XXX: gives us outline on backside of y-axis cpw.getPlotItem().setContentsMargins(*CHART_MARGINS) @@ -448,8 +534,8 @@ class LinkedSplits(QtWidgets.QWidget): # track by name self.subplots[name] = cpw - # XXX: we need this right? - self.splitter.addWidget(cpw) + sidepane.setMinimumWidth(self.chart.sidepane.width()) + self.splitter.addWidget(paned_chart if sidepane else cpw) # scale split regions self.set_split_sizes() @@ -832,7 +918,11 @@ class ChartPlotWidget(pg.PlotWidget): # one place to dig around this might be the `QBackingStore` # https://doc.qt.io/qt-5/qbackingstore.html # curve.setData(y=array[name], x=array['index'], **kwargs) - curve.update_from_array(x=array['index'], y=array[data_key], **kwargs) + curve.update_from_array( + x=array['index'], + y=array[data_key], + **kwargs + ) return curve @@ -969,7 +1059,7 @@ class ChartPlotWidget(pg.PlotWidget): self.scene().leaveEvent(ev) -_clear_throttle_rate: int = 50 # Hz +_clear_throttle_rate: int = 35 # Hz _book_throttle_rate: int = 16 # Hz @@ -1279,19 +1369,41 @@ async def run_fsp( group_key=group_status_key, ) - async with portal.open_stream_from( + async with ( + portal.open_stream_from( - # subactor entrypoint - fsp.cascade, + # subactor entrypoint + fsp.cascade, - # name as title of sub-chart - brokername=brokermod.name, - src_shm_token=src_shm.token, - dst_shm_token=conf['shm'].token, - symbol=sym, - fsp_func_name=fsp_func_name, + # name as title of sub-chart + brokername=brokermod.name, + src_shm_token=src_shm.token, + dst_shm_token=conf['shm'].token, + symbol=sym, + fsp_func_name=fsp_func_name, - ) as stream: + ) as stream, + + open_form( + godwidget=linkedsplits.godwidget, + parent=linkedsplits.godwidget, + fields={ + 'name': { + 'key': '**fsp**:', + 'type': 'select', + 'default_value': [ + f'{display_name}' + ], + }, + 'period': { + 'key': '**period (bars)**:', + 'type': 'edit', + 'default_value': 14, + }, + }, + ) as sidepane, + + ): # receive last index for processed historical # data-array as first msg @@ -1315,6 +1427,7 @@ async def run_fsp( array=shm.array, array_key=conf['fsp_func_name'], + sidepane=sidepane, # curve by default ohlc=False, @@ -1349,7 +1462,11 @@ async def run_fsp( # works also for overlays in which case data is looked up from # internal chart array set.... - chart.update_curve_from_array(display_name, shm.array, array_key=fsp_func_name) + chart.update_curve_from_array( + display_name, + shm.array, + array_key=fsp_func_name + ) # TODO: figure out if we can roll our own `FillToThreshold` to # get brush filled polygons for OS/OB conditions. @@ -1707,10 +1824,68 @@ async def _async_main( sbar = godwidget.window.status_bar starting_done = sbar.open_status('starting ze sexy chartz') - async with trio.open_nursery() as root_n: + async with ( + trio.open_nursery() as root_n, + open_form( + godwidget=godwidget, + parent=godwidget, + fields={ + 'account': { + 'key': '**account**:', + 'type': 'select', + 'default_value': [ + 'piker.paper', + # 'ib.margin', + # 'ib.paper', + ], + }, + 'disti_policy': { + 'key': '**entry policy**:', + 'type': 'select', + 'default_value': ['uniform'], + }, + 'slots': { + 'key': '**slots**:', + 'type': 'edit', + 'default_value': 4, + }, + 'allocator': { + 'key': '**allocator**:', + 'type': 'select', + 'default_value': ['$ size', '% of port', '# shares'], + }, + 'dollar_size': { + 'key': '**$size**:', + 'type': 'edit', + 'default_value': '5k', + }, + }, + ) as pp_config, + ): + pp_config: FieldsForm + mk_health_bar(pp_config) + pp_config.show() + # add as next-to-y-axis pane + godwidget.pp_config = pp_config + + # godwidget.hbox.insertWidget( + # 1, + # pp_config, + + # # alights to top and uses minmial space based on + # # search bar size hint (i think?) + # alignment=Qt.AlignTop + # ) + + godwidget.hbox.setAlignment(Qt.AlignTop) + + # sb = god_widget.window.statusBar() + # sb.insertPermanentWidget(0, pp_config) + # pp_config.show() # set root nursery and task stack for spawning other charts/feeds # that run cached in the bg + godwidget._root_n = root_n # setup search widget and focus main chart view at startup @@ -1761,63 +1936,7 @@ async def _async_main( # let key repeats pass through for search filter_auto_repeats=False, ), - - open_form( - godwidget=godwidget, - parent=godwidget, - fields={ - 'account': { - 'key': '**account**:', - 'type': 'select', - 'default_value': [ - 'piker.paper', - # 'ib.margin', - # 'ib.paper', - ], - }, - 'disti_policy': { - 'key': '**entry policy**:', - 'type': 'select', - 'default_value': ['uniform'], - }, - 'slots': { - 'key': '**slots**:', - 'type': 'edit', - 'default_value': 4, - }, - 'allocator': { - 'key': '**allocator**:', - 'type': 'select', - 'default_value': ['$ size', '% of port', '# shares'], - }, - 'dollar_size': { - 'key': '**$size**:', - 'type': 'edit', - 'default_value': '5k', - }, - }, - ) as pp_config, ): - pp_config: FieldsForm - pp_config.show() - - # add as next-to-y-axis pane - godwidget.pp_config = pp_config - - godwidget.hbox.insertWidget( - 1, - pp_config, - - # alights to top and uses minmial space based on - # search bar size hint (i think?) - alignment=Qt.AlignTop - ) - - godwidget.hbox.setAlignment(Qt.AlignTop) - # sb = god_widget.window.statusBar() - # sb.insertPermanentWidget(0, pp_config) - # pp_config.show() - # remove startup status text starting_done() await trio.sleep_forever() From 0ffbb15bc7f7821a4c7c2e965ec70ddf18c188ab Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 27 Jul 2021 06:11:47 -0400 Subject: [PATCH 061/178] Add a "health bar" factor B) --- piker/ui/_forms.py | 45 +++++++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/piker/ui/_forms.py b/piker/ui/_forms.py index 92dfb0f1..0742cab9 100644 --- a/piker/ui/_forms.py +++ b/piker/ui/_forms.py @@ -188,6 +188,8 @@ class FieldsForm(QWidget): font_size: Optional[int] = None, font_color: str = 'default_lightest', + **vbox_kwargs, + ) -> QtGui.QLabel: # add label to left of search bar @@ -212,7 +214,7 @@ class FieldsForm(QWidget): ) label.show() - self.vbox.addWidget(label) + self.vbox.addWidget(label, **vbox_kwargs) self.labels[name] = label return label @@ -360,15 +362,36 @@ async def open_form( values = list(config['default_value']) form.add_select_field(key, values) + async with open_handlers( + + list(form.fields.values()), + event_types={QEvent.KeyPress}, + + async_handler=partial( + handle_field_input, + form=form, + ), + + # block key repeats? + filter_auto_repeats=True, + ): + yield form + + +def mk_health_bar( + form: FieldsForm, + +) -> FieldsForm: + form.vbox.addSpacing(6) - form.add_field_label('fill status') + form.add_field_label('fill status', alignment=Qt.AlignCenter) form.vbox.addSpacing(6) fill_bar = QProgressBar(form) import math slots = 4 border_size_px = 2 - slot_margin_px = 2 #1.375 + slot_margin_px = 2 # 1.375 h = 150 #+ (2*2 + slot_margin_px*slots*2) # multiples, r = divmod(h, slots) slot_height_px = math.floor((h - 2*border_size_px)/slots) - slot_margin_px*1 @@ -431,18 +454,4 @@ async def open_form( form.vbox.addWidget(fill_bar, alignment=Qt.AlignCenter) # form.vbox.addStretch() - - async with open_handlers( - - list(form.fields.values()), - event_types={QEvent.KeyPress}, - - async_handler=partial( - handle_field_input, - form=form, - ), - - # block key repeats? - filter_auto_repeats=True, - ): - yield form + return form From b6c68e381db1c3c7f44694bb3c71a16a8859a5e5 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 27 Jul 2021 06:12:19 -0400 Subject: [PATCH 062/178] Move status back to gunmetal --- piker/ui/_window.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/piker/ui/_window.py b/piker/ui/_window.py index 6be62653..414c677d 100644 --- a/piker/ui/_window.py +++ b/piker/ui/_window.py @@ -165,7 +165,11 @@ class MainWindow(QtGui.QMainWindow): self._status_label = label = QtGui.QLabel() label.setStyleSheet( - f"QLabel {{ color : {hcolor('papas_special')}; }}" + f"""QLabel {{ + color : {hcolor('gunmetal')}; + }} + """ + # font-size : {font_size}px; ) label.setTextFormat(3) # markdown label.setFont(_font_small.font) From 318f3b45c5a416d71554798dbcf9fbc1e8c4f0bc Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 27 Jul 2021 10:41:51 -0400 Subject: [PATCH 063/178] Just always use a lambda ; it's innocuous --- piker/ui/_chart.py | 93 ++++++++++++++++------------------------------ 1 file changed, 32 insertions(+), 61 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 9bb0a041..7803fe12 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -412,52 +412,31 @@ class LinkedSplits(QWidget): self.xaxis.hide() self.xaxis = xaxis - if sidepane: + class LambdaQFrame(QFrame): + '''One-off ``QFrame`` which composes a layout + of a chart + sidepane ``FieldsForm`` (if provided). - class ChartandPane(QFrame): + ''' + sidepane: FieldsForm + hbox: QtGui.QHBoxLayout + chart: Optional['ChartPlotWidget'] = None - def __init__( - self, - parent=None, + def __init__( + self, + parent=None, - ) -> None: + ) -> None: - super().__init__(parent) + super().__init__(parent) - # self.chart = cpw - self.sidepane = sidepane - # cpw.chart_and_pane = self - # self.setStyleSheet( - # f"""QFrame {{ - # color : {hcolor('default_darkest')}; - # background-color : {hcolor('default_darkest')}; - # }} - # """ - # ) + self.sidepane = sidepane - hbox = self.hbox = QtGui.QHBoxLayout(self) - hbox.setAlignment(Qt.AlignTop | Qt.AlignLeft) - hbox.setContentsMargins(0, 0, 0, 0) - hbox.setSpacing(3) + hbox = self.hbox = QtGui.QHBoxLayout(self) + hbox.setAlignment(Qt.AlignTop | Qt.AlignLeft) + hbox.setContentsMargins(0, 0, 0, 0) + hbox.setSpacing(3) - def sizeHint(self) -> QtCore.QSize: - return self.chart.sizeHint() + sidepane.sizeHint() - - paned_chart = ChartandPane(parent=self.splitter) - - # splitter_widget = QWidget(self) - # splitter_widget.setSizePolicy( - # QSizePolicy.Expanding, - # QSizePolicy.Expanding, - # ) - - # hbox = QtGui.QHBoxLayout(splitter_widget) - # hbox.setAlignment(Qt.AlignTop | Qt.AlignRight) - # hbox.setContentsMargins(0, 0, 0, 0) - # hbox.setSpacing(3) - - # else: - # splitter_widget = cpw + qframe = LambdaQFrame(self.splitter) cpw = ChartPlotWidget( @@ -467,8 +446,7 @@ class LinkedSplits(QWidget): data_key=array_key or name, array=array, - # parent=self.splitter, - parent=paned_chart if sidepane else self.splitter, + parent=qframe, linkedsplits=self, axisItems={ 'bottom': xaxis, @@ -479,26 +457,17 @@ class LinkedSplits(QWidget): **cpw_kwargs, ) + qframe.chart = cpw + qframe.hbox.addWidget(cpw) + + # add sidepane **after** chart; place it on axis side if sidepane: - paned_chart.chart = cpw - paned_chart.hbox.addWidget(cpw) - # hbox.addWidget(cpw) - paned_chart.hbox.addWidget( + qframe.hbox.addWidget( sidepane, alignment=Qt.AlignTop ) cpw.sidepane = sidepane - # splitter_widget.setMinimumHeight(cpw.height()) - # splitter_widget.setMinimumWidth(cpw.width()) - # splitter_widget.show() - - # hbox.addWidget(cpw) - # hbox.addWidget(sidepane) - - # cpw.sidepane = sidepane - # cpw.hbox = hbox - # give viewbox as reference to chart # allowing for kb controls and interactions on **this** widget # (see our custom view mode in `._interactions.py`) @@ -534,8 +503,11 @@ class LinkedSplits(QWidget): # track by name self.subplots[name] = cpw - sidepane.setMinimumWidth(self.chart.sidepane.width()) - self.splitter.addWidget(paned_chart if sidepane else cpw) + if sidepane: + # TODO: use a "panes" collection to manage this? + sidepane.setMinimumWidth(self.chart.sidepane.width()) + + self.splitter.addWidget(qframe) # scale split regions self.set_split_sizes() @@ -683,8 +655,6 @@ class ChartPlotWidget(pg.PlotWidget): a = self._arrays['ohlc'] lbar = max(l, a[0]['index']) rbar = min(r, a[-1]['index']) - # lbar = max(l, 0) - # rbar = min(r, len(self._arrays['ohlc'])) return l, lbar, rbar, r def default_view( @@ -789,6 +759,7 @@ class ChartPlotWidget(pg.PlotWidget): pdi_kwargs.update(_pdi_defaults) data_key = array_key or name + # curve = pg.PlotDataItem( # curve = pg.PlotCurveItem( curve = FastAppendCurve( @@ -824,7 +795,7 @@ class ChartPlotWidget(pg.PlotWidget): # register curve graphics and backing array for name self._graphics[name] = curve - self._arrays[name] = data + self._arrays[data_key or name] = data if overlay: anchor_at = ('bottom', 'left') @@ -843,7 +814,7 @@ class ChartPlotWidget(pg.PlotWidget): if add_label: self.linked.cursor.contents_labels.add_label( self, - name, + data_key or name, anchor_at=anchor_at ) From e005c8b345bd84b3f0bd422640d6d773e3e56d54 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 30 Jul 2021 10:50:05 -0400 Subject: [PATCH 064/178] Use `QFormLayout` instead of rolling our own; add pp and feed status sections --- piker/ui/_forms.py | 229 +++++++++++++++++++++++++++++++-------------- 1 file changed, 160 insertions(+), 69 deletions(-) diff --git a/piker/ui/_forms.py b/piker/ui/_forms.py index 0742cab9..acd83d63 100644 --- a/piker/ui/_forms.py +++ b/piker/ui/_forms.py @@ -18,9 +18,11 @@ Text entry "forms" widgets (mostly for configuration and UI user input). ''' -from functools import partial -from typing import Optional from contextlib import asynccontextmanager +from functools import partial +from textwrap import dedent +import math +from typing import Optional import trio from PyQt5 import QtCore, QtGui @@ -163,23 +165,32 @@ class FieldsForm(QWidget): # size it as we specify self.setSizePolicy( - QSizePolicy.Fixed, - QSizePolicy.Fixed, + QSizePolicy.Expanding, + QSizePolicy.Expanding, ) # self.setMaximumHeight(30) - self.setMaximumWidth(166) + # self.setMaximumWidth(166) - # split layout for the (label:| text bar entry) self.vbox = QtGui.QVBoxLayout(self) self.vbox.setAlignment(Qt.AlignTop) - self.vbox.setContentsMargins(0, 0, 4, 0) - self.vbox.setSpacing(3) + self.vbox.setContentsMargins(0, 4, 4, 0) + self.vbox.setSpacing(0) + + # split layout for the (