From 8d324acf91df7983ca060922ad49ba74f30ec04a Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 19 Dec 2023 15:36:54 -0500 Subject: [PATCH] First (untested) draft remote annotation ctl API Since we can and want to eventually allow remote control of pretty much all UIs, this drafts out a new `.ui._remote_ctl` module with a new `@tractor.context` called `remote_annotate()` which simply starts a msg loop which allows for (eventual) initial control of a `SelectRect` through IPC msgs. Remote controller impl deats: - make `._display.graphics_update_loop()` set a `._remote_ctl._dss: dict` for all chart actor-global `DisplayState` instances which can then be controlled from the `remote_annotate()` handler task. - also stash any remote client controller `tractor.Context` handles in a module var for broadband IPC cancellation on any display loop shutdown. - draft a further global map to track graphics object instances since likely we'll want to support remote mutation where the client can use the `id(obj): int` key as an IPC handle/uuid. - just draft out a client-side `@acm` for now: `open_annots_client()` to be filled out in up coming commits. UI component tweaks in support of the above: - change/add `SelectRect.set_view_pos()` and `.set_scene_pos()` to allow specifying the rect coords in either of the scene or viewbox domains. - use these new apis in the interaction loop. - add a `SelectRect.add_to_view()` to avoid having annotation client code knowing "how" a graphics obj needs to be added and can instead just pass only the target `ChartView` during init. - drop all the status label updates from the display loop since they don't really work all the time, and probably it's not a feature we want to keep in the longer term (over just console output and/or using the status bar for simpler "current state / mkt" infos). - allows a bit of simplification of `.ui._fsp` method APIs to not pass around status (bar) callbacks as well! --- piker/ui/_curve.py | 15 ++--- piker/ui/_dataviz.py | 3 +- piker/ui/_display.py | 110 ++++++++++++++++++++---------------- piker/ui/_editors.py | 58 +++++++++++++++---- piker/ui/_fsp.py | 24 ++++---- piker/ui/_interaction.py | 18 ++++-- piker/ui/_remote_ctl.py | 119 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 262 insertions(+), 85 deletions(-) create mode 100644 piker/ui/_remote_ctl.py diff --git a/piker/ui/_curve.py b/piker/ui/_curve.py index c8e4c373..ff01c89d 100644 --- a/piker/ui/_curve.py +++ b/piker/ui/_curve.py @@ -56,8 +56,8 @@ _line_styles: dict[str, int] = { class FlowGraphic(pg.GraphicsObject): ''' - Base class with minimal interface for `QPainterPath` implemented, - real-time updated "data flow" graphics. + Base class with minimal interface for `QPainterPath` + implemented, real-time updated "data flow" graphics. See subtypes below. @@ -167,11 +167,12 @@ class FlowGraphic(pg.GraphicsObject): return None # XXX: due to a variety of weird jitter bugs and "smearing" - # artifacts when click-drag panning and viewing history time series, - # we offer this ctx-mngr interface to allow temporarily disabling - # Qt's graphics caching mode; this is now currently used from - # ``ChartView.start/signal_ic()`` methods which also disable the - # rt-display loop when the user is moving around a view. + # artifacts when click-drag panning and viewing history time + # series, we offer this ctx-mngr interface to allow temporarily + # disabling Qt's graphics caching mode; this is now currently + # used from ``ChartView.start/signal_ic()`` methods which also + # disable the rt-display loop when the user is moving around + # a view. @cm def reset_cache(self) -> None: try: diff --git a/piker/ui/_dataviz.py b/piker/ui/_dataviz.py index c18f5b67..41764e2b 100644 --- a/piker/ui/_dataviz.py +++ b/piker/ui/_dataviz.py @@ -563,7 +563,8 @@ class Viz(Struct): def view_range(self) -> tuple[int, int]: ''' - Return the start and stop x-indexes for the managed ``ViewBox``. + Return the start and stop x-indexes for the managed + ``ViewBox``. ''' vr = self.plot.viewRect() diff --git a/piker/ui/_display.py b/piker/ui/_display.py index e2bcbdb9..7c9870fa 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -470,54 +470,64 @@ async def graphics_update_loop( if ds.hist_vars['i_last'] < ds.hist_vars['i_last_append']: await tractor.pause() - # main real-time quotes update loop - stream: tractor.MsgStream - async with feed.open_multi_stream() as stream: - assert stream - async for quotes in stream: - quote_period = time.time() - last_quote_s - quote_rate = round( - 1/quote_period, 1) if quote_period > 0 else float('inf') - if ( - quote_period <= 1/_quote_throttle_rate + try: + from . import _remote_ctl + _remote_ctl._dss = dss - # in the absolute worst case we shouldn't see more then - # twice the expected throttle rate right!? - # and quote_rate >= _quote_throttle_rate * 2 - and quote_rate >= display_rate - ): - pass - # log.warning(f'High quote rate {mkt.fqme}: {quote_rate}') - - last_quote_s = time.time() - - for fqme, quote in quotes.items(): - ds = dss[fqme] - ds.quotes = quote - rt_pi, hist_pi = pis[fqme] - - # chart isn't active/shown so skip render cycle and - # pause feed(s) + # main real-time quotes update loop + stream: tractor.MsgStream + async with feed.open_multi_stream() as stream: + assert stream + async for quotes in stream: + quote_period = time.time() - last_quote_s + quote_rate = round( + 1/quote_period, 1) if quote_period > 0 else float('inf') if ( - fast_chart.linked.isHidden() - or not rt_pi.isVisible() + quote_period <= 1/_quote_throttle_rate + + # in the absolute worst case we shouldn't see more then + # twice the expected throttle rate right!? + # and quote_rate >= _quote_throttle_rate * 2 + and quote_rate >= display_rate ): - print(f'{fqme} skipping update for HIDDEN CHART') - fast_chart.pause_all_feeds() - continue + pass + # log.warning(f'High quote rate {mkt.fqme}: {quote_rate}') - ic = fast_chart.view._in_interact - if ic: - fast_chart.pause_all_feeds() - print(f'{fqme} PAUSING DURING INTERACTION') - await ic.wait() - fast_chart.resume_all_feeds() + last_quote_s = time.time() - # sync call to update all graphics/UX components. - graphics_update_cycle( - ds, - quote, - ) + for fqme, quote in quotes.items(): + ds = dss[fqme] + ds.quotes = quote + rt_pi, hist_pi = pis[fqme] + + # chart isn't active/shown so skip render cycle and + # pause feed(s) + if ( + fast_chart.linked.isHidden() + or not rt_pi.isVisible() + ): + print(f'{fqme} skipping update for HIDDEN CHART') + fast_chart.pause_all_feeds() + continue + + ic = fast_chart.view._in_interact + if ic: + fast_chart.pause_all_feeds() + print(f'{fqme} PAUSING DURING INTERACTION') + await ic.wait() + fast_chart.resume_all_feeds() + + # sync call to update all graphics/UX components. + graphics_update_cycle( + ds, + quote, + ) + + finally: + # XXX: cancel any remote annotation control ctxs + _remote_ctl._dss = None + for ctx in _remote_ctl._ctxs: + await ctx.cancel() def graphics_update_cycle( @@ -1235,7 +1245,7 @@ async def display_symbol_data( fast from a cached watch-list. ''' - sbar = godwidget.window.status_bar + # sbar = godwidget.window.status_bar # historical data fetch # brokermod = brokers.get_brokermod(provider) @@ -1245,11 +1255,11 @@ async def display_symbol_data( # group_key=loading_sym_key, # ) - for fqme in fqmes: - loading_sym_key = sbar.open_status( - f'loading {fqme} ->', - group_key=True - ) + # for fqme in fqmes: + # loading_sym_key = sbar.open_status( + # f'loading {fqme} ->', + # group_key=True + # ) # (TODO: make this not so shit XD) # close group status once a symbol feed fully loads to view. @@ -1422,7 +1432,7 @@ async def display_symbol_data( start_fsp_displays, rt_linked, flume, - loading_sym_key, + # loading_sym_key, loglevel, ) diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py index 96c1795f..8fb7bc7b 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -49,6 +49,7 @@ from ..log import get_logger if TYPE_CHECKING: from ._chart import GodWidget + from ._interaction import ChartView log = get_logger(__name__) @@ -261,7 +262,7 @@ class SelectRect(QtWidgets.QGraphicsRectItem): super().__init__(0, 0, 1, 1) # self.rbScaleBox = QGraphicsRectItem(0, 0, 1, 1) - self.vb = viewbox + self.vb: ViewBox = viewbox self._chart: 'ChartPlotWidget' = None # noqa # override selection box color @@ -297,6 +298,19 @@ class SelectRect(QtWidgets.QGraphicsRectItem): 'sigma: {std:.2f}', ] + def add_to_view( + self, + view: ChartView, + ) -> None: + ''' + Self-defined view hookup impl. + + ''' + view.addItem( + self, + ignoreBounds=True, + ) + @property def chart(self) -> 'ChartPlotWidget': # noqa return self._chart @@ -324,21 +338,40 @@ class SelectRect(QtWidgets.QGraphicsRectItem): self.vb.mapFromView(self._abs_top_right) ) - def set_pos( + def set_scen_pos( self, p1: QPointF, p2: QPointF + ) -> None: - """Set position of selection rect and accompanying label, move - label to match. + ''' + Set position from scene coords of selection rect (normally + from mouse position) and accompanying label, move label to + match. - """ + ''' + # map to view coords + self.set_view_pos( + self.vb.mapToView(p1), + self.vb.mapToView(p2), + ) + + def set_view_pos( + self, + start_pos: QPointF, + end_pos: QPointF, + + ) -> None: + ''' + Set position from `ViewBox` coords (i.e. from the actual + data domain) of rect (and any accompanying label which is + moved to match). + + ''' + # https://doc.qt.io/qt-5/qgraphicsproxywidget.html if self._label_proxy is None: - # https://doc.qt.io/qt-5/qgraphicsproxywidget.html - self._label_proxy = self.vb.scene().addWidget(self._label) - - start_pos = self.vb.mapToView(p1) - end_pos = self.vb.mapToView(p2) + self._label_proxy = self.vb.scene( + ).addWidget(self._label) # map to view coords and update area r = QtCore.QRectF(start_pos, end_pos) @@ -398,8 +431,9 @@ class SelectRect(QtWidgets.QGraphicsRectItem): # self._label.show() def clear(self): - """Clear the selection box from view. + ''' + Clear the selection box from view. - """ + ''' self._label.hide() self.hide() diff --git a/piker/ui/_fsp.py b/piker/ui/_fsp.py index 0227d60b..2e3e392e 100644 --- a/piker/ui/_fsp.py +++ b/piker/ui/_fsp.py @@ -560,7 +560,7 @@ class FspAdmin: conf: dict, # yeah probably dumb.. loglevel: str = 'error', - ) -> (trio.Event, ChartPlotWidget): + ) -> trio.Event: flume, started = await self.start_engine_task( target, @@ -927,7 +927,7 @@ async def start_fsp_displays( linked: LinkedSplits, flume: Flume, - group_status_key: str, + # group_status_key: str, loglevel: str, ) -> None: @@ -974,21 +974,23 @@ async def start_fsp_displays( flume, ) as admin, ): - statuses = [] + statuses: list[trio.Event] = [] for target, conf in fsp_conf.items(): - started = await admin.open_fsp_chart( + started: trio.Event = await admin.open_fsp_chart( target, conf, ) - done = linked.window().status_bar.open_status( - f'loading fsp, {target}..', - group_key=group_status_key, - ) - statuses.append((started, done)) + # done = linked.window().status_bar.open_status( + # f'loading fsp, {target}..', + # group_key=group_status_key, + # ) + # statuses.append((started, done)) + statuses.append(started) - for fsp_loaded, status_cb in statuses: + # for fsp_loaded, status_cb in statuses: + for fsp_loaded in statuses: await fsp_loaded.wait() profiler(f'attached to fsp portal: {target}') - status_cb() + # status_cb() # blocks on nursery until all fsp actors complete diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 3a21e6f4..1087519b 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -500,7 +500,11 @@ class ChartView(ViewBox): # add our selection box annotator self.select_box = SelectRect(self) - self.addItem(self.select_box, ignoreBounds=True) + self.select_box.add_to_view(self) + # self.addItem( + # self.select_box, + # ignoreBounds=True, + # ) self.mode = None self.order_mode: bool = False @@ -761,14 +765,17 @@ class ChartView(ViewBox): # This is the final position in the drag if ev.isFinish(): - self.select_box.mouse_drag_released(down_pos, pos) + self.select_box.mouse_drag_released( + down_pos, + pos, + ) # NOTE: think of this as a `.mouse_drag_release()` # (bc HINT that's what i called the shit ass # method that wrapped this call [yes, as a single # fucking call] originally.. you bish, guille) # Bo.. oraleeee - self.select_box.set_pos( + self.select_box.set_scen_pos( down_pos, pos, ) @@ -786,7 +793,10 @@ class ChartView(ViewBox): else: print('drag finish?') - self.select_box.set_pos(down_pos, pos) + self.select_box.set_scen_pos( + down_pos, + pos, + ) # update shape of scale box # self.updateScaleBox(ev.buttonDownPos(), ev.pos()) diff --git a/piker/ui/_remote_ctl.py b/piker/ui/_remote_ctl.py new file mode 100644 index 00000000..ce4dcd95 --- /dev/null +++ b/piker/ui/_remote_ctl.py @@ -0,0 +1,119 @@ +# 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 . + +''' +Remote control tasks for sending annotations (and maybe more cmds) +to a chart from some other actor. + +''' +from __future__ import annotations +from contextlib import asynccontextmanager as acm + +import tractor +# import trio +from PyQt5.QtWidgets import ( + QGraphicsItem, +) + +from piker.log import get_logger +from ._display import DisplayState +from ._editors import SelectRect +from ._chart import ChartPlotWidget + + +log = get_logger(__name__) + +# NOTE: this is set by the `._display.graphics_update_loop()` once +# all chart widgets / Viz per flume have been initialized allowing +# for remote annotation (control) of any chart-actor's mkt feed by +# fqme lookup Bo +_dss: dict[str, DisplayState] | None = None + +# stash each and every client connection so that they can all +# be cancelled on shutdown/error. +_ctxs: set[tractor.Context] = set() + +# global map of all uniquely created annotation-graphics +# so that they can be mutated (eventually) by a client. +_annots: dict[int, QGraphicsItem] = {} + + +@tractor.context +async def remote_annotate( + ctx: tractor.Context, +) -> None: + + global _dss, _ctxs + assert _dss + + _ctxs.add(ctx) + + # send back full fqme symbology to caller + await ctx.started(list(_dss)) + + async with ctx.open_stream() as annot_req_stream: + async for msg in annot_req_stream: + match msg: + case { + 'fqme': fqme, + 'cmd': 'SelectRect', + 'color': color, + 'timeframe': timeframe, + # 'meth': str(meth), + 'meth': 'set_view_pos', + 'kwargs': { + 'start_pos': tuple(start_pos), + 'end_pos': tuple(end_pos), + }, + }: + ds: DisplayState = _dss[fqme] + chart: ChartPlotWidget = { + 60: ds.hist_chart, + 1: ds.chart, + }[timeframe] + + # annot type lookup from cmd + rect = SelectRect( + chart.cv, + + # TODO: pull from conf.toml? + color=color or 'dad_blue', + ) + rect.set_view_pos( + start_pos=start_pos, + end_pos=end_pos, + ) + await annot_req_stream.send(id(rect)) + + case _: + log.error( + 'Unknown remote annotation cmd:\n' + f'{msg}' + ) + + +@acm +async def open_annots_client( + uid: tuple[str, str], + +) -> 'AnnotClient': + # TODO: load connetion to a specific chart actor + # -[ ] pull from either service scan or config + # -[ ] return some kinda client/proxy thinger? + # -[ ] maybe we should finally just provide this as + # a `tractor.hilevel.CallableProxy` or wtv? + # -[ ] use this from the storage.cli stuff to mark up gaps! + ...