diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 270255fc..e6dbd69f 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -29,7 +29,6 @@ from typing import ( ) import pyqtgraph as pg -import trio from piker.ui.qt import ( QtCore, @@ -41,6 +40,7 @@ from piker.ui.qt import ( QVBoxLayout, QSplitter, ) +from ._widget import GodWidget from ._axes import ( DynamicDateAxis, PriceAxis, @@ -61,10 +61,6 @@ from ._style import ( _xaxis_at, # _min_points_to_show, ) -from ..data.feed import ( - Feed, - Flume, -) from ..accounting import ( MktPair, ) @@ -78,305 +74,12 @@ from . import _pg_overrides as pgo if TYPE_CHECKING: from ._display import DisplayState + from ..data.flows import Flume + from ..data.feed import Feed log = get_logger(__name__) -_godw: GodWidget|None = None - -def get_godw() -> GodWidget: - ''' - Get the top level "god widget", the root/central-most Qt - widget-object set as `QMainWindow.setCentralWidget(_godw)`. - - See `piker.ui._exec` for the runtime init details and all the - machinery for running `trio` on the Qt event loop in guest mode. - - ''' - if _godw is None: - raise RuntimeError( - 'No god-widget initialized ??\n' - 'Have you called `run_qtractor()` yet?\n' - ) - return _godw - - -class GodWidget(QWidget): - ''' - "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 charts as well as other sub-widgets used to control or - modify them. - - ''' - search: SearchWidget - mode_name: str = 'god' - - def __init__( - - self, - parent=None, - - ) -> None: - - super().__init__(parent) - - self.search: SearchWidget|None = None - - self.hbox = QHBoxLayout(self) - self.hbox.setContentsMargins(0, 0, 0, 0) - self.hbox.setSpacing(6) - self.hbox.setAlignment(Qt.AlignTop) - - self.vbox = QVBoxLayout() - self.vbox.setContentsMargins(0, 0, 0, 0) - self.vbox.setSpacing(2) - self.vbox.setAlignment(Qt.AlignTop) - - self.hbox.addLayout(self.vbox) - - self._chart_cache: dict[ - str, - tuple[LinkedSplits, LinkedSplits], - ] = {} - - self.hist_linked: LinkedSplits|None = None - self.rt_linked: LinkedSplits|None = None - self._active_cursor: Cursor|None = None - - # assigned in the startup func `_async_main()` - self._root_n: trio.Nursery = None - - self._widgets: dict[str, QWidget] = {} - self._resizing: bool = False - - # TODO: do we need this, when would god get resized - # and the window does not? Never right?! - # self.reg_for_resize(self) - - # TODO: strat loader/saver that we don't need yet. - # def init_strategy_ui(self): - # self.toolbar_layout = QHBoxLayout() - # self.toolbar_layout.setContentsMargins(0, 0, 0, 0) - # self.vbox.addLayout(self.toolbar_layout) - # self.strategy_box = StrategyBoxWidget(self) - # self.toolbar_layout.addWidget(self.strategy_box) - - @property - def linkedsplits(self) -> LinkedSplits: - return self.rt_linked - - def set_chart_symbols( - self, - group_key: tuple[str], # of form . - all_linked: tuple[LinkedSplits, LinkedSplits], # type: ignore - - ) -> None: - # re-sort org cache symbol list in LIFO order - cache = self._chart_cache - cache.pop(group_key, None) - cache[group_key] = all_linked - - def get_chart_symbols( - self, - symbol_key: str, - - ) -> tuple[LinkedSplits, LinkedSplits]: # type: ignore - return self._chart_cache.get(symbol_key) - - async def load_symbols( - self, - fqmes: list[str], - loglevel: str, - reset: bool = False, - - ) -> trio.Event: - ''' - Load a new contract into the charting app. - - Expects a ``numpy`` structured array containing all the ohlcv fields. - - ''' - # NOTE: for now we use the first symbol in the set as the "key" - # for the overlay of feeds on the chart. - group_key: tuple[str] = tuple(fqmes) - - all_linked = self.get_chart_symbols(group_key) - order_mode_started = trio.Event() - - if not self.vbox.isEmpty(): - - # XXX: seems to make switching slower? - # qframe = self.hist_linked.chart.qframe - # if qframe.sidepane is self.search: - # qframe.hbox.removeWidget(self.search) - - for linked in [self.rt_linked, self.hist_linked]: - # XXX: this is CRITICAL especially with pixel buffer caching - linked.hide() - linked.unfocus() - - # XXX: pretty sure we don't need this - # remove any existing plots? - # XXX: ahh we might want to support cache unloading.. - # self.vbox.removeWidget(linked) - - # switching to a new viewable chart - if all_linked is None or reset: - from ._display import display_symbol_data - - # we must load a fresh linked charts set - self.rt_linked = rt_charts = LinkedSplits(self) - self.hist_linked = hist_charts = LinkedSplits(self) - - # spawn new task to start up and update new sub-chart instances - self._root_n.start_soon( - display_symbol_data, - self, - fqmes, - loglevel, - order_mode_started, - ) - - # self.vbox.addWidget(hist_charts) - self.vbox.addWidget(rt_charts) - self.set_chart_symbols( - group_key, - (hist_charts, rt_charts), - ) - - for linked in [hist_charts, rt_charts]: - linked.show() - linked.focus() - - await trio.sleep(0) - - else: - # symbol is already loaded and ems ready - order_mode_started.set() - - self.hist_linked, self.rt_linked = all_linked - - for linked in all_linked: - # TODO: - # - we'll probably want per-instrument/provider state here? - # change the order config form over to the new chart - - # chart is already in memory so just focus it - linked.show() - linked.focus() - linked.graphics_cycle() - await trio.sleep(0) - - # resume feeds *after* rendering chart view asap - chart = linked.chart - if chart: - chart.resume_all_feeds() - - # TODO: we need a check to see if the chart - # last had the xlast in view, if so then shift so it's - # still in view, if the user was viewing history then - # do nothing yah? - self.rt_linked.chart.main_viz.default_view( - do_min_bars=True, - ) - - # if a history chart instance is already up then - # set the search widget as its sidepane. - hist_chart = self.hist_linked.chart - if hist_chart: - hist_chart.qframe.set_sidepane(self.search) - - # NOTE: this is really stupid/hard to follow. - # we have to reposition the active position nav - # **AFTER** applying the search bar as a sidepane - # to the newly switched to symbol. - await trio.sleep(0) - - # TODO: probably stick this in some kinda `LooknFeel` API? - for tracker in self.rt_linked.mode.trackers.values(): - pp_nav = tracker.nav - if tracker.live_pp.cumsize: - pp_nav.show() - pp_nav.hide_info() - else: - pp_nav.hide() - - # set window titlebar info - symbol = self.rt_linked.mkt - if symbol is not None: - self.window.setWindowTitle( - f'{symbol.fqme} ' - f'tick:{symbol.size_tick}' - ) - - return order_mode_started - - def focus(self) -> None: - ''' - Focus the top level widget which in turn focusses the chart - ala "view mode". - - ''' - # go back to view-mode focus (aka chart focus) - self.clearFocus() - chart = self.rt_linked.chart - if chart: - chart.setFocus() - - def reg_for_resize( - self, - widget: QWidget, - ) -> None: - getattr(widget, 'on_resize') - self._widgets[widget.mode_name] = widget - - def on_win_resize(self, event: QtCore.QEvent) -> None: - ''' - Top level god widget handler from window (the real yaweh) resize - events such that any registered widgets which wish to be - notified are invoked using our pythonic `.on_resize()` method - api. - - Where we do UX magic to make things not suck B) - - ''' - if self._resizing: - return - - self._resizing = True - - log.info('God widget resize') - for name, widget in self._widgets.items(): - widget.on_resize() - - self._resizing = False - - # on_resize = on_win_resize - - def get_cursor(self) -> Cursor: - return self._active_cursor - - def iter_linked(self) -> Iterator[LinkedSplits]: - for linked in [self.hist_linked, self.rt_linked]: - yield linked - - def resize_all(self) -> None: - ''' - Dynamic resize sequence: adjusts all sub-widgets/charts to - sensible default ratios of what space is detected as available - on the display / window. - - ''' - rt_linked = self.rt_linked - rt_linked.set_split_sizes() - self.rt_linked.resize_sidepanes() - self.hist_linked.resize_sidepanes(from_linked=rt_linked) - self.search.on_resize() - - class ChartnPane(QFrame): ''' One-off ``QFrame`` composite which pairs a chart @@ -438,7 +141,6 @@ class LinkedSplits(QWidget): ''' def __init__( - self, godwidget: GodWidget, @@ -1050,7 +752,7 @@ class ChartPlotWidget(pg.PlotWidget): ) -> None: ''' - Increment the data view ``datums``` steps toward y-axis thus + Increment the data view `datums`` steps toward y-axis thus "following" the current time slot/step/bar. ''' @@ -1060,7 +762,7 @@ class ChartPlotWidget(pg.PlotWidget): x_shift = viz.index_step() * datums if datums >= 300: - print("FUCKING FIX THE GLOBAL STEP BULLSHIT") + log.warning('FUCKING FIX THE GLOBAL STEP BULLSHIT') # breakpoint() return diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py index d426b3c3..5d2ee9d5 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -55,6 +55,11 @@ from ._style import ( from ._lines import LevelLine from ..log import get_logger +# TODO, rm the cycle here! +from ._widget import ( + GodWidget, +) + if TYPE_CHECKING: from ._chart import ( GodWidget, diff --git a/piker/ui/_exec.py b/piker/ui/_exec.py index 3643786d..9c1fb923 100644 --- a/piker/ui/_exec.py +++ b/piker/ui/_exec.py @@ -56,7 +56,7 @@ from . import _style if TYPE_CHECKING: - from ._chart import GodWidget + from ._widget import GodWidget log = get_logger(__name__) diff --git a/piker/ui/_widget.py b/piker/ui/_widget.py new file mode 100644 index 00000000..b6a7322e --- /dev/null +++ b/piker/ui/_widget.py @@ -0,0 +1,346 @@ +# 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 . + +''' +Root-most (what they call a "central widget") of every Qt-UI-app's +window. + +''' +from __future__ import annotations +from typing import ( + Iterator, + TYPE_CHECKING, +) + +import trio + +from piker.ui.qt import ( + QtCore, + Qt, + QWidget, + QHBoxLayout, + QVBoxLayout, +) +from ..log import get_logger + +if TYPE_CHECKING: + from ._search import SearchWidget + from ._chart import ( + LinkedSplits, + ) + from ._cursor import ( + Cursor, + ) + + +log = get_logger(__name__) + +_godw: GodWidget|None = None + +def get_godw() -> GodWidget: + ''' + Get the top level "god widget", the root/central-most Qt + widget-object set as `QMainWindow.setCentralWidget(_godw)`. + + See `piker.ui._exec` for the runtime init details and all the + machinery for running `trio` on the Qt event loop in guest mode. + + ''' + if _godw is None: + raise RuntimeError( + 'No god-widget initialized ??\n' + 'Have you called `run_qtractor()` yet?\n' + ) + return _godw + + +class GodWidget(QWidget): + ''' + "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 charts as well as other sub-widgets used to control or + modify them. + + ''' + search: SearchWidget + mode_name: str = 'god' + + def __init__( + + self, + parent=None, + + ) -> None: + + super().__init__(parent) + + self.search: SearchWidget|None = None + + self.hbox = QHBoxLayout(self) + self.hbox.setContentsMargins(0, 0, 0, 0) + self.hbox.setSpacing(6) + self.hbox.setAlignment(Qt.AlignTop) + + self.vbox = QVBoxLayout() + self.vbox.setContentsMargins(0, 0, 0, 0) + self.vbox.setSpacing(2) + self.vbox.setAlignment(Qt.AlignTop) + + self.hbox.addLayout(self.vbox) + + self._chart_cache: dict[ + str, + tuple[LinkedSplits, LinkedSplits], + ] = {} + + self.hist_linked: LinkedSplits|None = None + self.rt_linked: LinkedSplits|None = None + self._active_cursor: Cursor|None = None + + # assigned in the startup func `_async_main()` + self._root_n: trio.Nursery = None + + self._widgets: dict[str, QWidget] = {} + self._resizing: bool = False + + # TODO: do we need this, when would god get resized + # and the window does not? Never right?! + # self.reg_for_resize(self) + + # TODO: strat loader/saver that we don't need yet. + # def init_strategy_ui(self): + # self.toolbar_layout = QHBoxLayout() + # self.toolbar_layout.setContentsMargins(0, 0, 0, 0) + # self.vbox.addLayout(self.toolbar_layout) + # self.strategy_box = StrategyBoxWidget(self) + # self.toolbar_layout.addWidget(self.strategy_box) + + @property + def linkedsplits(self) -> LinkedSplits: + return self.rt_linked + + def set_chart_symbols( + self, + group_key: tuple[str], # of form . + all_linked: tuple[LinkedSplits, LinkedSplits], # type: ignore + + ) -> None: + # re-sort org cache symbol list in LIFO order + cache = self._chart_cache + cache.pop(group_key, None) + cache[group_key] = all_linked + + def get_chart_symbols( + self, + symbol_key: str, + + ) -> tuple[LinkedSplits, LinkedSplits]: # type: ignore + return self._chart_cache.get(symbol_key) + + async def load_symbols( + self, + fqmes: list[str], + loglevel: str, + reset: bool = False, + + ) -> trio.Event: + ''' + Load a new contract into the charting app. + + Expects a ``numpy`` structured array containing all the ohlcv fields. + + ''' + # NOTE: for now we use the first symbol in the set as the "key" + # for the overlay of feeds on the chart. + group_key: tuple[str] = tuple(fqmes) + + all_linked = self.get_chart_symbols(group_key) + order_mode_started = trio.Event() + + if not self.vbox.isEmpty(): + + # XXX: seems to make switching slower? + # qframe = self.hist_linked.chart.qframe + # if qframe.sidepane is self.search: + # qframe.hbox.removeWidget(self.search) + + for linked in [self.rt_linked, self.hist_linked]: + # XXX: this is CRITICAL especially with pixel buffer caching + linked.hide() + linked.unfocus() + + # XXX: pretty sure we don't need this + # remove any existing plots? + # XXX: ahh we might want to support cache unloading.. + # self.vbox.removeWidget(linked) + + # switching to a new viewable chart + if all_linked is None or reset: + from ._display import display_symbol_data + + # we must load a fresh linked charts set + from ._chart import LinkedSplits + self.rt_linked = rt_charts = LinkedSplits(self) + self.hist_linked = hist_charts = LinkedSplits(self) + + # spawn new task to start up and update new sub-chart instances + self._root_n.start_soon( + display_symbol_data, + self, + fqmes, + loglevel, + order_mode_started, + ) + + # self.vbox.addWidget(hist_charts) + self.vbox.addWidget(rt_charts) + self.set_chart_symbols( + group_key, + (hist_charts, rt_charts), + ) + + for linked in [hist_charts, rt_charts]: + linked.show() + linked.focus() + + await trio.sleep(0) + + else: + # symbol is already loaded and ems ready + order_mode_started.set() + + self.hist_linked, self.rt_linked = all_linked + + for linked in all_linked: + # TODO: + # - we'll probably want per-instrument/provider state here? + # change the order config form over to the new chart + + # chart is already in memory so just focus it + linked.show() + linked.focus() + linked.graphics_cycle() + await trio.sleep(0) + + # resume feeds *after* rendering chart view asap + chart = linked.chart + if chart: + chart.resume_all_feeds() + + # TODO: we need a check to see if the chart + # last had the xlast in view, if so then shift so it's + # still in view, if the user was viewing history then + # do nothing yah? + self.rt_linked.chart.main_viz.default_view( + do_min_bars=True, + ) + + # if a history chart instance is already up then + # set the search widget as its sidepane. + hist_chart = self.hist_linked.chart + if hist_chart: + hist_chart.qframe.set_sidepane(self.search) + + # NOTE: this is really stupid/hard to follow. + # we have to reposition the active position nav + # **AFTER** applying the search bar as a sidepane + # to the newly switched to symbol. + await trio.sleep(0) + + # TODO: probably stick this in some kinda `LooknFeel` API? + for tracker in self.rt_linked.mode.trackers.values(): + pp_nav = tracker.nav + if tracker.live_pp.cumsize: + pp_nav.show() + pp_nav.hide_info() + else: + pp_nav.hide() + + # set window titlebar info + symbol = self.rt_linked.mkt + if symbol is not None: + self.window.setWindowTitle( + f'{symbol.fqme} ' + f'tick:{symbol.size_tick}' + ) + + return order_mode_started + + def focus(self) -> None: + ''' + Focus the top level widget which in turn focusses the chart + ala "view mode". + + ''' + # go back to view-mode focus (aka chart focus) + self.clearFocus() + chart = self.rt_linked.chart + if chart: + chart.setFocus() + + def reg_for_resize( + self, + widget: QWidget, + ) -> None: + getattr(widget, 'on_resize') + self._widgets[widget.mode_name] = widget + + def on_win_resize(self, event: QtCore.QEvent) -> None: + ''' + Top level god widget handler from window (the real yaweh) resize + events such that any registered widgets which wish to be + notified are invoked using our pythonic `.on_resize()` method + api. + + Where we do UX magic to make things not suck B) + + ''' + if self._resizing: + return + + self._resizing = True + + log.info('God widget resize') + for name, widget in self._widgets.items(): + widget.on_resize() + + self._resizing = False + + # on_resize = on_win_resize + + def get_cursor(self) -> Cursor: + return self._active_cursor + + def iter_linked(self) -> Iterator[LinkedSplits]: + for linked in [self.hist_linked, self.rt_linked]: + yield linked + + def resize_all(self) -> None: + ''' + Dynamic resize sequence: adjusts all sub-widgets/charts to + sensible default ratios of what space is detected as available + on the display / window. + + ''' + rt_linked = self.rt_linked + rt_linked.set_split_sizes() + self.rt_linked.resize_sidepanes() + self.hist_linked.resize_sidepanes(from_linked=rt_linked) + self.search.on_resize() + + diff --git a/piker/ui/_window.py b/piker/ui/_window.py index 12f4209a..39335092 100644 --- a/piker/ui/_window.py +++ b/piker/ui/_window.py @@ -40,7 +40,7 @@ from piker.ui.qt import ( ) from ..log import get_logger from ._style import _font_small, hcolor -from ._chart import GodWidget +from ._widget import GodWidget log = get_logger(__name__)