Convert view box to async input handling

Instead of callbacks for key presses/releases convert our `ChartView`'s
kb input handling to async code using our event relaying-over-mem-chan
system. This is a first step toward a more async driven modal control
UX. Changed a bunch of "chart" component naming as part of this as well,
namely: `ChartSpace` -> `GodWidget` and `LinkedSplitCharts` ->
`LinkedSplits`. Engage the view boxe's async handler code as part of new
symbol data loading in `display_symbol_data()`. More re-orging to come!
asyncify_input_modes
Tyler Goodlet 2021-06-15 18:19:59 -04:00
parent 85621af8af
commit 75804a441c
3 changed files with 306 additions and 219 deletions

View File

@ -26,6 +26,7 @@ from functools import partial
from PyQt5 import QtCore, QtGui from PyQt5 import QtCore, QtGui
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt
from PyQt5.QtCore import QEvent
import numpy as np import numpy as np
import pyqtgraph as pg import pyqtgraph as pg
import tractor import tractor
@ -60,7 +61,7 @@ from ._style import (
_bars_to_left_in_follow_mode, _bars_to_left_in_follow_mode,
) )
from . import _search from . import _search
from ._event import open_key_stream from . import _event
from ..data._source import Symbol from ..data._source import Symbol
from ..data._sharedmem import ShmArray from ..data._sharedmem import ShmArray
from ..data import maybe_open_shm_array from ..data import maybe_open_shm_array
@ -77,13 +78,23 @@ from ..data import feed
log = get_logger(__name__) log = get_logger(__name__)
class ChartSpace(QtGui.QWidget): class GodWidget(QtGui.QWidget):
'''Highest level composed widget which contains layouts for '''
"Our lord and savior, the holy child of window-shua, there is no
widget above thee." - 6|6
The highest level composed widget which contains layouts for
organizing lower level charts as well as other widgets used to organizing lower level charts as well as other widgets used to
control or modify them. control or modify them.
''' '''
def __init__(self, parent=None): def __init__(
self,
parent=None,
) -> None:
super().__init__(parent) super().__init__(parent)
self.hbox = QtGui.QHBoxLayout(self) self.hbox = QtGui.QHBoxLayout(self)
@ -96,51 +107,54 @@ class ChartSpace(QtGui.QWidget):
self.hbox.addLayout(self.vbox) self.hbox.addLayout(self.vbox)
self.toolbar_layout = QtGui.QHBoxLayout() # self.toolbar_layout = QtGui.QHBoxLayout()
self.toolbar_layout.setContentsMargins(0, 0, 0, 0) # self.toolbar_layout.setContentsMargins(0, 0, 0, 0)
# self.vbox.addLayout(self.toolbar_layout)
# self.init_timeframes_ui() # self.init_timeframes_ui()
# self.init_strategy_ui() # self.init_strategy_ui()
self.vbox.addLayout(self.toolbar_layout)
# self.vbox.addLayout(self.hbox) # self.vbox.addLayout(self.hbox)
self._chart_cache = {} self._chart_cache = {}
self.linkedcharts: 'LinkedSplitCharts' = None self.linkedsplits: 'LinkedSplits' = None
self._root_n: Optional[trio.Nursery] = None
# assigned in the startup func `_async_main()`
self._root_n: trio.Nursery = None
self._task_stack: AsyncExitStack = None
def set_chart_symbol( def set_chart_symbol(
self, self,
symbol_key: str, # of form <fqsn>.<providername> symbol_key: str, # of form <fqsn>.<providername>
linked_charts: 'LinkedSplitCharts', # type: ignore linkedsplits: 'LinkedSplits', # type: ignore
) -> None: ) -> None:
# re-sort org cache symbol list in LIFO order # re-sort org cache symbol list in LIFO order
cache = self._chart_cache cache = self._chart_cache
cache.pop(symbol_key, None) cache.pop(symbol_key, None)
cache[symbol_key] = linked_charts cache[symbol_key] = linkedsplits
def get_chart_symbol( def get_chart_symbol(
self, self,
symbol_key: str, symbol_key: str,
) -> 'LinkedSplitCharts': # type: ignore ) -> 'LinkedSplits': # type: ignore
return self._chart_cache.get(symbol_key) return self._chart_cache.get(symbol_key)
def init_timeframes_ui(self): # def init_timeframes_ui(self):
self.tf_layout = QtGui.QHBoxLayout() # self.tf_layout = QtGui.QHBoxLayout()
self.tf_layout.setSpacing(0) # self.tf_layout.setSpacing(0)
self.tf_layout.setContentsMargins(0, 12, 0, 0) # self.tf_layout.setContentsMargins(0, 12, 0, 0)
time_frames = ('1M', '5M', '15M', '30M', '1H', '1D', '1W', 'MN') # time_frames = ('1M', '5M', '15M', '30M', '1H', '1D', '1W', 'MN')
btn_prefix = 'TF' # btn_prefix = 'TF'
for tf in time_frames: # for tf in time_frames:
btn_name = ''.join([btn_prefix, tf]) # btn_name = ''.join([btn_prefix, tf])
btn = QtGui.QPushButton(tf) # btn = QtGui.QPushButton(tf)
# TODO: # # TODO:
btn.setEnabled(False) # btn.setEnabled(False)
setattr(self, btn_name, btn) # setattr(self, btn_name, btn)
self.tf_layout.addWidget(btn) # self.tf_layout.addWidget(btn)
self.toolbar_layout.addLayout(self.tf_layout) # self.toolbar_layout.addLayout(self.tf_layout)
# XXX: strat loader/saver that we don't need yet. # XXX: strat loader/saver that we don't need yet.
# def init_strategy_ui(self): # def init_strategy_ui(self):
@ -166,46 +180,46 @@ class ChartSpace(QtGui.QWidget):
# fully qualified symbol name (SNS i guess is what we're making?) # fully qualified symbol name (SNS i guess is what we're making?)
fqsn = '.'.join([symbol_key, providername]) fqsn = '.'.join([symbol_key, providername])
linkedcharts = self.get_chart_symbol(fqsn) linkedsplits = self.get_chart_symbol(fqsn)
if not self.vbox.isEmpty(): if not self.vbox.isEmpty():
# XXX: this is CRITICAL especially with pixel buffer caching # XXX: this is CRITICAL especially with pixel buffer caching
self.linkedcharts.hide() self.linkedsplits.hide()
# XXX: pretty sure we don't need this # XXX: pretty sure we don't need this
# remove any existing plots? # remove any existing plots?
# XXX: ahh we might want to support cache unloading.. # XXX: ahh we might want to support cache unloading..
self.vbox.removeWidget(self.linkedcharts) self.vbox.removeWidget(self.linkedsplits)
# switching to a new viewable chart # switching to a new viewable chart
if linkedcharts is None or reset: if linkedsplits is None or reset:
# we must load a fresh linked charts set # we must load a fresh linked charts set
linkedcharts = LinkedSplitCharts(self) linkedsplits = LinkedSplits(self)
# spawn new task to start up and update new sub-chart instances # spawn new task to start up and update new sub-chart instances
self._root_n.start_soon( self._root_n.start_soon(
chart_symbol, display_symbol_data,
self, self,
providername, providername,
symbol_key, symbol_key,
loglevel, loglevel,
) )
self.set_chart_symbol(fqsn, linkedcharts) self.set_chart_symbol(fqsn, linkedsplits)
self.vbox.addWidget(linkedcharts) self.vbox.addWidget(linkedsplits)
# chart is already in memory so just focus it # chart is already in memory so just focus it
if self.linkedcharts: if self.linkedsplits:
self.linkedcharts.unfocus() self.linkedsplits.unfocus()
# self.vbox.addWidget(linkedcharts) # self.vbox.addWidget(linkedsplits)
linkedcharts.show() linkedsplits.show()
linkedcharts.focus() linkedsplits.focus()
self.linkedcharts = linkedcharts self.linkedsplits = linkedsplits
symbol = linkedcharts.symbol symbol = linkedsplits.symbol
if symbol is not None: if symbol is not None:
self.window.setWindowTitle( self.window.setWindowTitle(
@ -214,14 +228,16 @@ class ChartSpace(QtGui.QWidget):
) )
class LinkedSplitCharts(QtGui.QWidget): class LinkedSplits(QtGui.QWidget):
"""Widget that holds a central chart plus derived '''
Widget that holds a central chart plus derived
subcharts computed from the original data set apart subcharts computed from the original data set apart
by splitters for resizing. by splitters for resizing.
A single internal references to the data is maintained A single internal references to the data is maintained
for each chart and can be updated externally. for each chart and can be updated externally.
"""
'''
long_pen = pg.mkPen('#006000') long_pen = pg.mkPen('#006000')
long_brush = pg.mkBrush('#00ff00') long_brush = pg.mkBrush('#00ff00')
short_pen = pg.mkPen('#600000') short_pen = pg.mkPen('#600000')
@ -232,21 +248,24 @@ class LinkedSplitCharts(QtGui.QWidget):
def __init__( def __init__(
self, self,
chart_space: ChartSpace, godwidget: GodWidget,
) -> None: ) -> None:
super().__init__() super().__init__()
self.signals_visible: bool = False
# self.signals_visible: bool = False
self._cursor: Cursor = None # crosshair graphics self._cursor: Cursor = None # crosshair graphics
self.godwidget = godwidget
self.chart: ChartPlotWidget = None # main (ohlc) chart self.chart: ChartPlotWidget = None # main (ohlc) chart
self.subplots: Dict[Tuple[str, ...], ChartPlotWidget] = {} self.subplots: Dict[Tuple[str, ...], ChartPlotWidget] = {}
self.chart_space = chart_space
self.chart_space = chart_space self.godwidget = godwidget
self.xaxis = DynamicDateAxis( self.xaxis = DynamicDateAxis(
orientation='bottom', orientation='bottom',
linked_charts=self linkedsplits=self
) )
# if _xaxis_at == 'bottom': # if _xaxis_at == 'bottom':
# self.xaxis.setStyle(showValues=False) # self.xaxis.setStyle(showValues=False)
@ -302,7 +321,7 @@ class LinkedSplitCharts(QtGui.QWidget):
""" """
# add crosshairs # add crosshairs
self._cursor = Cursor( self._cursor = Cursor(
linkedsplitcharts=self, linkedsplits=self,
digits=symbol.digits(), digits=symbol.digits(),
) )
self.chart = self.add_plot( self.chart = self.add_plot(
@ -342,14 +361,14 @@ class LinkedSplitCharts(QtGui.QWidget):
"A main plot must be created first with `.plot_ohlc_main()`") "A main plot must be created first with `.plot_ohlc_main()`")
# source of our custom interactions # source of our custom interactions
cv = ChartView() cv = ChartView(name)
cv.linked_charts = self cv.linkedsplits = self
# use "indicator axis" by default # use "indicator axis" by default
if xaxis is None: if xaxis is None:
xaxis = DynamicDateAxis( xaxis = DynamicDateAxis(
orientation='bottom', orientation='bottom',
linked_charts=self linkedsplits=self
) )
cpw = ChartPlotWidget( cpw = ChartPlotWidget(
@ -360,11 +379,11 @@ class LinkedSplitCharts(QtGui.QWidget):
array=array, array=array,
parent=self.splitter, parent=self.splitter,
linked_charts=self, linkedsplits=self,
axisItems={ axisItems={
'bottom': xaxis, 'bottom': xaxis,
'right': PriceAxis(linked_charts=self, orientation='right'), 'right': PriceAxis(linkedsplits=self, orientation='right'),
'left': PriceAxis(linked_charts=self, orientation='left'), 'left': PriceAxis(linkedsplits=self, orientation='left'),
}, },
viewBox=cv, viewBox=cv,
cursor=self._cursor, cursor=self._cursor,
@ -377,7 +396,7 @@ class LinkedSplitCharts(QtGui.QWidget):
# (see our custom view mode in `._interactions.py`) # (see our custom view mode in `._interactions.py`)
cv.chart = cpw cv.chart = cpw
cpw.plotItem.vb.linked_charts = self cpw.plotItem.vb.linkedsplits = self
cpw.setFrameStyle(QtGui.QFrame.StyledPanel) # | QtGui.QFrame.Plain) cpw.setFrameStyle(QtGui.QFrame.StyledPanel) # | QtGui.QFrame.Plain)
cpw.hideButtons() cpw.hideButtons()
# XXX: gives us outline on backside of y-axis # XXX: gives us outline on backside of y-axis
@ -415,7 +434,8 @@ class LinkedSplitCharts(QtGui.QWidget):
class ChartPlotWidget(pg.PlotWidget): class ChartPlotWidget(pg.PlotWidget):
"""``GraphicsView`` subtype containing a single ``PlotItem``. '''
``GraphicsView`` subtype containing a single ``PlotItem``.
- The added methods allow for plotting OHLC sequences from - The added methods allow for plotting OHLC sequences from
``np.ndarray``s with appropriate field names. ``np.ndarray``s with appropriate field names.
@ -425,7 +445,8 @@ class ChartPlotWidget(pg.PlotWidget):
(Could be replaced with a ``pg.GraphicsLayoutWidget`` if we (Could be replaced with a ``pg.GraphicsLayoutWidget`` if we
eventually want multiple plots managed together?) eventually want multiple plots managed together?)
"""
'''
sig_mouse_leave = QtCore.Signal(object) sig_mouse_leave = QtCore.Signal(object)
sig_mouse_enter = QtCore.Signal(object) sig_mouse_enter = QtCore.Signal(object)
@ -441,7 +462,7 @@ class ChartPlotWidget(pg.PlotWidget):
# the data view we generate graphics from # the data view we generate graphics from
name: str, name: str,
array: np.ndarray, array: np.ndarray,
linked_charts: LinkedSplitCharts, linkedsplits: LinkedSplits,
view_color: str = 'papas_special', view_color: str = 'papas_special',
pen_color: str = 'bracket', pen_color: str = 'bracket',
@ -465,7 +486,7 @@ class ChartPlotWidget(pg.PlotWidget):
**kwargs **kwargs
) )
self.name = name self.name = name
self._lc = linked_charts self._lc = linkedsplits
# scene-local placeholder for book graphics # scene-local placeholder for book graphics
# sizing to avoid overlap with data contents # sizing to avoid overlap with data contents
@ -1006,10 +1027,12 @@ _book_throttle_rate: int = 16 # Hz
async def chart_from_quotes( async def chart_from_quotes(
chart: ChartPlotWidget, chart: ChartPlotWidget,
stream, stream: tractor.MsgStream,
ohlcv: np.ndarray, ohlcv: np.ndarray,
wap_in_history: bool = False, wap_in_history: bool = False,
) -> None: ) -> None:
"""The 'main' (price) chart real-time update loop. """The 'main' (price) chart real-time update loop.
@ -1211,12 +1234,14 @@ async def chart_from_quotes(
async def spawn_fsps( async def spawn_fsps(
linked_charts: LinkedSplitCharts,
linkedsplits: LinkedSplits,
fsps: Dict[str, str], fsps: Dict[str, str],
sym, sym,
src_shm, src_shm,
brokermod, brokermod,
loglevel, loglevel,
) -> None: ) -> None:
"""Start financial signal processing in subactor. """Start financial signal processing in subactor.
@ -1224,7 +1249,7 @@ async def spawn_fsps(
""" """
linked_charts.focus() linkedsplits.focus()
# spawns sub-processes which execute cpu bound FSP code # spawns sub-processes which execute cpu bound FSP code
async with tractor.open_nursery(loglevel=loglevel) as n: async with tractor.open_nursery(loglevel=loglevel) as n:
@ -1271,7 +1296,7 @@ async def spawn_fsps(
ln.start_soon( ln.start_soon(
run_fsp, run_fsp,
portal, portal,
linked_charts, linkedsplits,
brokermod, brokermod,
sym, sym,
src_shm, src_shm,
@ -1286,7 +1311,7 @@ async def spawn_fsps(
async def run_fsp( async def run_fsp(
portal: tractor._portal.Portal, portal: tractor._portal.Portal,
linked_charts: LinkedSplitCharts, linkedsplits: LinkedSplits,
brokermod: ModuleType, brokermod: ModuleType,
sym: str, sym: str,
src_shm: ShmArray, src_shm: ShmArray,
@ -1300,7 +1325,7 @@ async def run_fsp(
This is called once for each entry in the fsp This is called once for each entry in the fsp
config map. config map.
""" """
done = linked_charts.window().status_bar.open_status( done = linkedsplits.window().status_bar.open_status(
f'loading FSP: {display_name}..') f'loading FSP: {display_name}..')
async with portal.open_stream_from( async with portal.open_stream_from(
@ -1324,7 +1349,7 @@ async def run_fsp(
shm = conf['shm'] shm = conf['shm']
if conf.get('overlay'): if conf.get('overlay'):
chart = linked_charts.chart chart = linkedsplits.chart
chart.draw_curve( chart.draw_curve(
name='vwap', name='vwap',
data=shm.array, data=shm.array,
@ -1334,7 +1359,7 @@ async def run_fsp(
else: else:
chart = linked_charts.add_plot( chart = linkedsplits.add_plot(
name=fsp_func_name, name=fsp_func_name,
array=shm.array, array=shm.array,
@ -1358,7 +1383,7 @@ async def run_fsp(
chart._shm = shm chart._shm = shm
# should **not** be the same sub-chart widget # should **not** be the same sub-chart widget
assert chart.name != linked_charts.chart.name assert chart.name != linkedsplits.chart.name
# sticky only on sub-charts atm # sticky only on sub-charts atm
last_val_sticky = chart._ysticks[chart.name] last_val_sticky = chart._ysticks[chart.name]
@ -1441,7 +1466,7 @@ async def run_fsp(
last = now last = now
async def check_for_new_bars(feed, ohlcv, linked_charts): async def check_for_new_bars(feed, ohlcv, linkedsplits):
"""Task which updates from new bars in the shared ohlcv buffer every """Task which updates from new bars in the shared ohlcv buffer every
``delay_s`` seconds. ``delay_s`` seconds.
@ -1451,7 +1476,7 @@ async def check_for_new_bars(feed, ohlcv, linked_charts):
# Likely the best way to solve this is to make this task # Likely the best way to solve this is to make this task
# aware of the instrument's tradable hours? # aware of the instrument's tradable hours?
price_chart = linked_charts.chart price_chart = linkedsplits.chart
price_chart.default_view() price_chart.default_view()
async with feed.index_stream() as stream: async with feed.index_stream() as stream:
@ -1489,55 +1514,63 @@ async def check_for_new_bars(feed, ohlcv, linked_charts):
price_chart._arrays[name] price_chart._arrays[name]
) )
for name, chart in linked_charts.subplots.items(): 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)
# shift the view if in follow mode # shift the view if in follow mode
price_chart.increment_view() price_chart.increment_view()
async def chart_symbol( async def display_symbol_data(
chart_app: ChartSpace,
godwidget: GodWidget,
provider: str, provider: str,
sym: str, sym: str,
loglevel: str, loglevel: str,
) -> None: ) -> None:
"""Spawn a real-time chart widget for this symbol and app session. '''Spawn a real-time displayed and updated chart for provider symbol.
These widgets can remain up but hidden so that multiple symbols Spawned ``LinkedSplits`` chart widgets can remain up but hidden so
can be viewed and switched between extremely fast. that multiple symbols can be viewed and switched between extremely
fast from a cached watch-list.
""" '''
sbar = chart_app.window.status_bar sbar = godwidget.window.status_bar
loading_sym_done = sbar.open_status(f'loading {sym}.{provider}..') loading_sym_done = sbar.open_status(f'loading {sym}.{provider}..')
# historical data fetch # historical data fetch
brokermod = brokers.get_brokermod(provider) brokermod = brokers.get_brokermod(provider)
async with data.open_feed( async with(
provider, data.open_feed(
[sym], provider,
loglevel=loglevel, [sym],
loglevel=loglevel,
# 60 FPS to limit context switches # 60 FPS to limit context switches
tick_throttle=_clear_throttle_rate, tick_throttle=_clear_throttle_rate,
) as feed: ) as feed,
trio.open_nursery() as n,
):
ohlcv: ShmArray = feed.shm ohlcv: ShmArray = feed.shm
bars = ohlcv.array bars = ohlcv.array
symbol = feed.symbols[sym] symbol = feed.symbols[sym]
# load in symbol's ohlc data # load in symbol's ohlc data
chart_app.window.setWindowTitle( godwidget.window.setWindowTitle(
f'{symbol.key}@{symbol.brokers} ' f'{symbol.key}@{symbol.brokers} '
f'tick:{symbol.tick_size}' f'tick:{symbol.tick_size}'
) )
linked_charts = chart_app.linkedcharts linkedsplits = godwidget.linkedsplits
linked_charts._symbol = symbol linkedsplits._symbol = symbol
chart = linked_charts.plot_ohlc_main(symbol, bars)
chart = linkedsplits.plot_ohlc_main(symbol, bars)
chart.setFocus() chart.setFocus()
# plot historical vwap if available # plot historical vwap if available
@ -1591,54 +1624,59 @@ async def chart_symbol(
}, },
}) })
async with trio.open_nursery() as n:
# load initial fsp chain (otherwise known as "indicators") # load initial fsp chain (otherwise known as "indicators")
n.start_soon( n.start_soon(
spawn_fsps, spawn_fsps,
linked_charts, linkedsplits,
fsp_conf, fsp_conf,
sym, sym,
ohlcv, ohlcv,
brokermod, brokermod,
loglevel, loglevel,
) )
# start graphics update loop(s)after receiving first live quote # start graphics update loop(s)after receiving first live quote
n.start_soon( n.start_soon(
chart_from_quotes, chart_from_quotes,
chart, chart,
feed.stream, feed.stream,
ohlcv, ohlcv,
wap_in_history, wap_in_history,
) )
# wait for a first quote before we start any update tasks # wait for a first quote before we start any update tasks
# quote = await feed.receive() # quote = await feed.receive()
# log.info(f'Received first quote {quote}') # log.info(f'Received first quote {quote}')
n.start_soon( n.start_soon(
check_for_new_bars, check_for_new_bars,
feed, feed,
# delay, # delay,
ohlcv, ohlcv,
linked_charts linkedsplits
) )
# interactive testing # interactive testing
# n.start_soon( # n.start_soon(
# test_bed, # test_bed,
# ohlcv, # ohlcv,
# chart, # chart,
# linked_charts, # linkedsplits,
# ) # )
# start async input handling for chart's view
# await godwidget._task_stack.enter_async_context(
async with chart._vb.open_async_input_handler():
await start_order_mode(chart, symbol, provider) await start_order_mode(chart, symbol, provider)
async def load_providers( async def load_providers(
brokernames: list[str], brokernames: list[str],
loglevel: str, loglevel: str,
) -> None: ) -> None:
# TODO: seems like our incentive for brokerd caching lelel # TODO: seems like our incentive for brokerd caching lelel
@ -1673,8 +1711,9 @@ async def load_providers(
async def _async_main( async def _async_main(
# implicit required argument provided by ``qtractor_run()`` # implicit required argument provided by ``qtractor_run()``
main_widget: ChartSpace, main_widget: GodWidget,
sym: str, sym: str,
brokernames: str, brokernames: str,
@ -1688,13 +1727,14 @@ async def _async_main(
""" """
chart_app = main_widget godwidget = main_widget
# attempt to configure DPI aware font size # attempt to configure DPI aware font size
screen = chart_app.window.current_screen() screen = godwidget.window.current_screen()
# configure graphics update throttling based on display refresh rate # configure graphics update throttling based on display refresh rate
global _clear_throttle_rate global _clear_throttle_rate
_clear_throttle_rate = min( _clear_throttle_rate = min(
round(screen.refreshRate()), round(screen.refreshRate()),
_clear_throttle_rate, _clear_throttle_rate,
@ -1702,37 +1742,39 @@ async def _async_main(
log.info(f'Set graphics update rate to {_clear_throttle_rate} Hz') log.info(f'Set graphics update rate to {_clear_throttle_rate} Hz')
# TODO: do styling / themeing setup # TODO: do styling / themeing setup
# _style.style_ze_sheets(chart_app) # _style.style_ze_sheets(godwidget)
sbar = chart_app.window.status_bar sbar = godwidget.window.status_bar
starting_done = sbar.open_status('starting ze chartz...') starting_done = sbar.open_status('starting ze chartz...')
async with trio.open_nursery() as root_n: async with (
trio.open_nursery() as root_n,
AsyncExitStack() as chart_task_stack,
):
# set root nursery for spawning other charts/feeds # set root nursery and task stack for spawning other charts/feeds
# that run cached in the bg # that run cached in the bg
chart_app._root_n = root_n godwidget._root_n = root_n
godwidget._task_stack = chart_task_stack
# setup search widget # setup search widget and focus main chart view at startup
search = _search.SearchWidget(chart_space=chart_app) search = _search.SearchWidget(godwidget=godwidget)
# the main chart's view is given focus at startup
search.bar.unfocus() search.bar.unfocus()
# add search singleton to global chart-space widget # add search singleton to global chart-space widget
chart_app.hbox.addWidget( godwidget.hbox.addWidget(
search, search,
# alights to top and uses minmial space based on # alights to top and uses minmial space based on
# search bar size hint (i think?) # search bar size hint (i think?)
alignment=Qt.AlignTop alignment=Qt.AlignTop
) )
chart_app.search = search godwidget.search = search
symbol, _, provider = sym.rpartition('.') symbol, _, provider = sym.rpartition('.')
# this internally starts a ``chart_symbol()`` task above # this internally starts a ``display_symbol_data()`` task above
chart_app.load_symbol(provider, symbol, loglevel) godwidget.load_symbol(provider, symbol, loglevel)
# spin up a search engine for the local cached symbol set # spin up a search engine for the local cached symbol set
async with _search.register_symbol_search( async with _search.register_symbol_search(
@ -1740,7 +1782,7 @@ async def _async_main(
provider_name='cache', provider_name='cache',
search_routine=partial( search_routine=partial(
_search.search_simple_dict, _search.search_simple_dict,
source=chart_app._chart_cache, source=godwidget._chart_cache,
), ),
# cache is super fast so debounce on super short period # cache is super fast so debounce on super short period
pause_period=0.01, pause_period=0.01,
@ -1751,18 +1793,17 @@ async def _async_main(
root_n.start_soon(load_providers, brokernames, loglevel) root_n.start_soon(load_providers, brokernames, loglevel)
# start handling search bar kb inputs # start handling search bar kb inputs
async with open_key_stream( async with (
search.bar,
) as key_stream:
# start kb handling task for searcher _event.open_handler(
root_n.start_soon( search.bar,
_search.handle_keyboard_input, event_types={QEvent.KeyPress},
# chart_app, async_handler=_search.handle_keyboard_input,
search, # let key repeats pass through for search
key_stream, filter_auto_repeats=False,
) )
):
# remove startup status text
starting_done() starting_done()
await trio.sleep_forever() await trio.sleep_forever()
@ -1780,6 +1821,6 @@ def _main(
run_qtractor( run_qtractor(
func=_async_main, func=_async_main,
args=(sym, brokernames, piker_loglevel), args=(sym, brokernames, piker_loglevel),
main_widget=ChartSpace, main_widget=GodWidget,
tractor_kwargs=tractor_kwargs, tractor_kwargs=tractor_kwargs,
) )

View File

@ -18,14 +18,17 @@
Chart view box primitives Chart view box primitives
""" """
from contextlib import asynccontextmanager
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional, Dict from typing import Optional, Dict
import pyqtgraph as pg import pyqtgraph as pg
from PyQt5.QtCore import QPointF from PyQt5.QtCore import QPointF, Qt
from PyQt5.QtCore import QEvent
from pyqtgraph import ViewBox, Point, QtCore, QtGui from pyqtgraph import ViewBox, Point, QtCore, QtGui
from pyqtgraph import functions as fn from pyqtgraph import functions as fn
import numpy as np import numpy as np
import trio
from ..log import get_logger from ..log import get_logger
from ._style import _min_points_to_show, hcolor, _font from ._style import _min_points_to_show, hcolor, _font
@ -457,8 +460,28 @@ class ArrowEditor:
self.chart.plotItem.removeItem(arrow) self.chart.plotItem.removeItem(arrow)
async def handle_viewmode_inputs(
view: 'ChartView',
recv_chan: trio.abc.ReceiveChannel,
) -> None:
async for event, etype, key, mods, text in recv_chan:
log.debug(f'key: {key}, mods: {mods}, text: {text}')
if etype in {QEvent.KeyPress}:
await view.on_key_press(text, key, mods)
elif etype in {QEvent.KeyRelease}:
await view.on_key_release(text, key, mods)
class ChartView(ViewBox): class ChartView(ViewBox):
"""Price chart view box with interaction behaviors you'd expect from '''
Price chart view box with interaction behaviors you'd expect from
any interactive platform: any interactive platform:
- zoom on mouse scroll that auto fits y-axis - zoom on mouse scroll that auto fits y-axis
@ -466,23 +489,30 @@ class ChartView(ViewBox):
- zoom on x to most recent in view datum - zoom on x to most recent in view datum
- zoom on right-click-n-drag to cursor position - zoom on right-click-n-drag to cursor position
""" '''
mode_name: str = 'mode: view' mode_name: str = 'mode: view'
def __init__( def __init__(
self, self,
name: str,
parent: pg.PlotItem = None, parent: pg.PlotItem = None,
**kwargs, **kwargs,
): ):
super().__init__(parent=parent, **kwargs) super().__init__(parent=parent, **kwargs)
# disable vertical scrolling # disable vertical scrolling
self.setMouseEnabled(x=True, y=False) self.setMouseEnabled(x=True, y=False)
self.linked_charts = None
self.select_box = SelectRect(self) self.linkedsplits = None
self.addItem(self.select_box, ignoreBounds=True)
self._chart: 'ChartPlotWidget' = None # noqa self._chart: 'ChartPlotWidget' = None # noqa
# add our selection box annotator
self.select_box = SelectRect(self)
self.addItem(self.select_box, ignoreBounds=True)
self.name = name
self.mode = None self.mode = None
# kb ctrls processing # kb ctrls processing
@ -491,6 +521,19 @@ class ChartView(ViewBox):
self.setFocusPolicy(QtCore.Qt.StrongFocus) self.setFocusPolicy(QtCore.Qt.StrongFocus)
@asynccontextmanager
async def open_async_input_handler(
self,
) -> 'ChartView':
from . import _event
async with _event.open_handler(
self,
event_types={QEvent.KeyPress, QEvent.KeyRelease},
async_handler=handle_viewmode_inputs,
):
yield self
@property @property
def chart(self) -> 'ChartPlotWidget': # type: ignore # noqa def chart(self) -> 'ChartPlotWidget': # type: ignore # noqa
return self._chart return self._chart
@ -501,21 +544,21 @@ class ChartView(ViewBox):
self.select_box.chart = chart self.select_box.chart = chart
def wheelEvent(self, ev, axis=None): def wheelEvent(self, ev, axis=None):
"""Override "center-point" location for scrolling. '''Override "center-point" location for scrolling.
This is an override of the ``ViewBox`` method simply changing This is an override of the ``ViewBox`` method simply changing
the center of the zoom to be the y-axis. the center of the zoom to be the y-axis.
TODO: PR a method into ``pyqtgraph`` to make this configurable TODO: PR a method into ``pyqtgraph`` to make this configurable
"""
'''
if axis in (0, 1): if axis in (0, 1):
mask = [False, False] mask = [False, False]
mask[axis] = self.state['mouseEnabled'][axis] mask[axis] = self.state['mouseEnabled'][axis]
else: else:
mask = self.state['mouseEnabled'][:] mask = self.state['mouseEnabled'][:]
chart = self.linked_charts.chart chart = self.linkedsplits.chart
# don't zoom more then the min points setting # don't zoom more then the min points setting
l, lbar, rbar, r = chart.bars_range() l, lbar, rbar, r = chart.bars_range()
@ -573,7 +616,6 @@ class ChartView(ViewBox):
end_of_l1, end_of_l1,
key=lambda p: p.x() key=lambda p: p.x()
) )
# breakpoint()
# focal = pg.Point(last_bar.x() + end_of_l1) # focal = pg.Point(last_bar.x() + end_of_l1)
self._resetTarget() self._resetTarget()
@ -697,22 +739,26 @@ class ChartView(ViewBox):
ev.accept() ev.accept()
self.mode.submit_exec() self.mode.submit_exec()
def keyReleaseEvent(self, ev: QtCore.QEvent): # def keyReleaseEvent(self, ev: QtCore.QEvent):
def keyReleaseEvent(self, event: QtCore.QEvent) -> None:
'''This routine is rerouted to an async handler.
'''
pass
async def on_key_release(
self,
text: str,
key: int, # 3-digit
mods: int, # 7-digit
) -> None:
""" """
Key release to normally to trigger release of input mode Key release to normally to trigger release of input mode
""" """
# TODO: is there a global setting for this? if key == Qt.Key_Shift:
if ev.isAutoRepeat():
ev.ignore()
return
ev.accept()
# text = ev.text()
key = ev.key()
mods = ev.modifiers()
if key == QtCore.Qt.Key_Shift:
# if self.state['mouseMode'] == ViewBox.RectMode: # if self.state['mouseMode'] == ViewBox.RectMode:
self.setMouseMode(ViewBox.PanMode) self.setMouseMode(ViewBox.PanMode)
@ -722,39 +768,37 @@ class ChartView(ViewBox):
# if self.state['mouseMode'] == ViewBox.RectMode: # if self.state['mouseMode'] == ViewBox.RectMode:
# if key == QtCore.Qt.Key_Space: # if key == QtCore.Qt.Key_Space:
if mods == QtCore.Qt.ControlModifier or key == QtCore.Qt.Key_Control: if mods == Qt.ControlModifier or key == QtCore.Qt.Key_Control:
self.mode._exec_mode = 'dark' self.mode._exec_mode = 'dark'
if key in {QtCore.Qt.Key_A, QtCore.Qt.Key_F, QtCore.Qt.Key_D}: if key in {Qt.Key_A, Qt.Key_F, Qt.Key_D}:
# remove "staged" level line under cursor position # remove "staged" level line under cursor position
self.mode.lines.unstage_line() self.mode.lines.unstage_line()
self._key_active = False self._key_active = False
def keyPressEvent(self, ev: QtCore.QEvent) -> None: def keyPressEvent(self, event: QtCore.QEvent) -> None:
""" '''This routine is rerouted to an async handler.
This routine should capture key presses in the current view box. '''
pass
""" async def on_key_press(
# TODO: is there a global setting for this?
if ev.isAutoRepeat():
ev.ignore()
return
ev.accept() self,
text = ev.text()
key = ev.key()
mods = ev.modifiers()
print(f'text: {text}, key: {key}') text: str,
key: int, # 3-digit
mods: int, # 7-digit
if mods == QtCore.Qt.ShiftModifier: ) -> None:
if mods == Qt.ShiftModifier:
if self.state['mouseMode'] == ViewBox.PanMode: if self.state['mouseMode'] == ViewBox.PanMode:
self.setMouseMode(ViewBox.RectMode) self.setMouseMode(ViewBox.RectMode)
# ctrl # ctrl
ctrl = False ctrl = False
if mods == QtCore.Qt.ControlModifier: if mods == Qt.ControlModifier:
ctrl = True ctrl = True
self.mode._exec_mode = 'live' self.mode._exec_mode = 'live'
@ -767,20 +811,20 @@ class ChartView(ViewBox):
# ctlr-<space>/<l> for "lookup", "search" -> open search tree # ctlr-<space>/<l> for "lookup", "search" -> open search tree
if ctrl and key in { if ctrl and key in {
QtCore.Qt.Key_L, Qt.Key_L,
QtCore.Qt.Key_Space, Qt.Key_Space,
}: }:
search = self._chart._lc.chart_space.search search = self._chart._lc.godwidget.search
search.focus() search.focus()
# esc # esc
if key == QtCore.Qt.Key_Escape or (ctrl and key == QtCore.Qt.Key_C): if key == Qt.Key_Escape or (ctrl and key == Qt.Key_C):
# ctrl-c as cancel # ctrl-c as cancel
# https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9 # https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9
self.select_box.clear() self.select_box.clear()
# cancel order or clear graphics # cancel order or clear graphics
if key == QtCore.Qt.Key_C or key == QtCore.Qt.Key_Delete: if key == Qt.Key_C or key == Qt.Key_Delete:
# delete any lines under the cursor # delete any lines under the cursor
mode = self.mode mode = self.mode
for line in mode.lines.lines_under_cursor(): for line in mode.lines.lines_under_cursor():
@ -789,18 +833,18 @@ class ChartView(ViewBox):
self._key_buffer.append(text) self._key_buffer.append(text)
# View modes # View modes
if key == QtCore.Qt.Key_R: if key == Qt.Key_R:
self.chart.default_view() self.chart.default_view()
# Order modes: stage orders at the current cursor level # Order modes: stage orders at the current cursor level
elif key == QtCore.Qt.Key_D: # for "damp eet" elif key == Qt.Key_D: # for "damp eet"
self.mode.set_exec('sell') self.mode.set_exec('sell')
elif key == QtCore.Qt.Key_F: # for "fillz eet" elif key == Qt.Key_F: # for "fillz eet"
self.mode.set_exec('buy') self.mode.set_exec('buy')
elif key == QtCore.Qt.Key_A: elif key == Qt.Key_A:
self.mode.set_exec('alert') self.mode.set_exec('alert')
# XXX: Leaving this for light reference purposes, there # XXX: Leaving this for light reference purposes, there
@ -808,7 +852,7 @@ class ChartView(ViewBox):
# Key presses are used only when mouse mode is RectMode # Key presses are used only when mouse mode is RectMode
# The following events are implemented: # The following events are implemented:
# ctrl-A : zooms out to the default "full" view of the plot # ctrl-A : zooms out to the default "full" self of the plot
# ctrl-+ : moves forward in the zooming stack (if it exists) # ctrl-+ : moves forward in the zooming stack (if it exists)
# ctrl-- : moves backward in the zooming stack (if it exists) # ctrl-- : moves backward in the zooming stack (if it exists)
@ -819,5 +863,5 @@ class ChartView(ViewBox):
# self.scaleHistory(len(self.axHistory)) # self.scaleHistory(len(self.axHistory))
else: else:
# maybe propagate to parent widget # maybe propagate to parent widget
ev.ignore() # event.ignore()
self._key_active = False self._key_active = False

View File

@ -447,7 +447,7 @@ class SearchBar(QtWidgets.QLineEdit):
self.view: CompleterView = view self.view: CompleterView = view
self.dpi_font = font self.dpi_font = font
self.chart_app = parent_chart self.godwidget = parent_chart
# size it as we specify # size it as we specify
# https://doc.qt.io/qt-5/qsizepolicy.html#Policy-enum # https://doc.qt.io/qt-5/qsizepolicy.html#Policy-enum
@ -496,12 +496,12 @@ class SearchWidget(QtGui.QWidget):
def __init__( def __init__(
self, self,
chart_space: 'ChartSpace', # type: ignore # noqa godwidget: 'GodWidget', # type: ignore # noqa
columns: List[str] = ['src', 'symbol'], columns: List[str] = ['src', 'symbol'],
parent=None, parent=None,
) -> None: ) -> None:
super().__init__(parent or chart_space) super().__init__(parent or godwidget)
# size it as we specify # size it as we specify
self.setSizePolicy( self.setSizePolicy(
@ -509,7 +509,7 @@ class SearchWidget(QtGui.QWidget):
QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed,
) )
self.chart_app = chart_space self.godwidget = godwidget
self.vbox = QtGui.QVBoxLayout(self) self.vbox = QtGui.QVBoxLayout(self)
self.vbox.setContentsMargins(0, 0, 0, 0) self.vbox.setContentsMargins(0, 0, 0, 0)
@ -540,7 +540,7 @@ class SearchWidget(QtGui.QWidget):
) )
self.bar = SearchBar( self.bar = SearchBar(
parent=self, parent=self,
parent_chart=chart_space, parent_chart=godwidget,
view=self.view, view=self.view,
) )
self.bar_hbox.addWidget(self.bar) self.bar_hbox.addWidget(self.bar)
@ -557,7 +557,7 @@ class SearchWidget(QtGui.QWidget):
# fill cache list if nothing existing # fill cache list if nothing existing
self.view.set_section_entries( self.view.set_section_entries(
'cache', 'cache',
list(reversed(self.chart_app._chart_cache)), list(reversed(self.godwidget._chart_cache)),
clear_all=True, clear_all=True,
) )
@ -611,7 +611,7 @@ class SearchWidget(QtGui.QWidget):
return None return None
provider, symbol = value provider, symbol = value
chart = self.chart_app chart = self.godwidget
log.info(f'Requesting symbol: {symbol}.{provider}') log.info(f'Requesting symbol: {symbol}.{provider}')
@ -632,7 +632,7 @@ class SearchWidget(QtGui.QWidget):
# Re-order the symbol cache on the chart to display in # Re-order the symbol cache on the chart to display in
# LIFO order. this is normally only done internally by # LIFO order. this is normally only done internally by
# the chart on new symbols being loaded into memory # the chart on new symbols being loaded into memory
chart.set_chart_symbol(fqsn, chart.linkedcharts) chart.set_chart_symbol(fqsn, chart.linkedsplits)
self.view.set_section_entries( self.view.set_section_entries(
'cache', 'cache',
@ -650,6 +650,7 @@ _search_enabled: bool = False
async def pack_matches( async def pack_matches(
view: CompleterView, view: CompleterView,
has_results: dict[str, set[str]], has_results: dict[str, set[str]],
matches: dict[(str, str), [str]], matches: dict[(str, str), [str]],
@ -823,7 +824,7 @@ async def fill_results(
async def handle_keyboard_input( async def handle_keyboard_input(
search: SearchWidget, searchbar: SearchBar,
recv_chan: trio.abc.ReceiveChannel, recv_chan: trio.abc.ReceiveChannel,
) -> None: ) -> None:
@ -831,8 +832,9 @@ async def handle_keyboard_input(
global _search_active, _search_enabled global _search_active, _search_enabled
# startup # startup
chart = search.chart_app bar = searchbar
bar = search.bar search = searchbar.parent()
chart = search.godwidget
view = bar.view view = bar.view
view.set_font_size(bar.dpi_font.px_size) view.set_font_size(bar.dpi_font.px_size)
@ -851,7 +853,7 @@ async def handle_keyboard_input(
) )
) )
async for event, key, mods, txt in recv_chan: async for event, etype, key, mods, txt in recv_chan:
log.debug(f'key: {key}, mods: {mods}, txt: {txt}') log.debug(f'key: {key}, mods: {mods}, txt: {txt}')
@ -889,7 +891,7 @@ async def handle_keyboard_input(
# kill the search and focus back on main chart # kill the search and focus back on main chart
if chart: if chart:
chart.linkedcharts.focus() chart.linkedsplits.focus()
continue continue