Compare commits
51 Commits
e937b60ed6
...
62e9c74377
| Author | SHA1 | Date |
|---|---|---|
|
|
62e9c74377 | |
|
|
ba622b285f | |
|
|
ad7fc649b2 | |
|
|
6149fcaf2e | |
|
|
4ff8be18c5 | |
|
|
2758c030a5 | |
|
|
04ef91bbf8 | |
|
|
11e5e1e2a0 | |
|
|
c8b6fe62cf | |
|
|
f4bbfe566d | |
|
|
c2b1ccac2c | |
|
|
61c6feff5c | |
|
|
4fbb3f6770 | |
|
|
7d7fd2964e | |
|
|
2a3901fd7d | |
|
|
d260667c59 | |
|
|
ccba910247 | |
|
|
d67c34014c | |
|
|
ea9a5d0236 | |
|
|
8695c418ce | |
|
|
8ebe2971db | |
|
|
a87e066667 | |
|
|
3ab89dc8b2 | |
|
|
4a1bc4ef01 | |
|
|
fa69764086 | |
|
|
8e1d5522fc | |
|
|
9f66ee0998 | |
|
|
60e2e0c2e6 | |
|
|
4479fc46cd | |
|
|
7cd9d0dfda | |
|
|
9a6df8d51a | |
|
|
51e8a56c44 | |
|
|
c5ddbd53c9 | |
|
|
ae4b0528ea | |
|
|
b292da59c0 | |
|
|
0e51e4161d | |
|
|
e7be9f9838 | |
|
|
37a697674d | |
|
|
7eab2021d6 | |
|
|
2cf52e24d6 | |
|
|
12403331d8 | |
|
|
c9f2dcac53 | |
|
|
600636784c | |
|
|
0b63a73954 | |
|
|
8fb47f761a | |
|
|
ad37ebabb2 | |
|
|
5020266bd5 | |
|
|
d6a56d87bf | |
|
|
e8152b8534 | |
|
|
bb81c74353 | |
|
|
7eaf28479c |
228
piker/types.py
228
piker/types.py
|
|
@ -21,230 +21,6 @@ Extensions to built-in or (heavily used but 3rd party) friend-lib
|
|||
types.
|
||||
|
||||
'''
|
||||
from __future__ import annotations
|
||||
from collections import UserList
|
||||
from pprint import (
|
||||
saferepr,
|
||||
from tractor.msg.pretty_struct import (
|
||||
Struct as Struct,
|
||||
)
|
||||
from typing import Any
|
||||
|
||||
from msgspec import (
|
||||
msgpack,
|
||||
Struct as _Struct,
|
||||
structs,
|
||||
)
|
||||
|
||||
|
||||
class DiffDump(UserList):
|
||||
'''
|
||||
Very simple list delegator that repr() dumps (presumed) tuple
|
||||
elements of the form `tuple[str, Any, Any]` in a nice
|
||||
multi-line readable form for analyzing `Struct` diffs.
|
||||
|
||||
'''
|
||||
def __repr__(self) -> str:
|
||||
if not len(self):
|
||||
return super().__repr__()
|
||||
|
||||
# format by displaying item pair's ``repr()`` on multiple,
|
||||
# indented lines such that they are more easily visually
|
||||
# comparable when printed to console when printed to
|
||||
# console.
|
||||
repstr: str = '[\n'
|
||||
for k, left, right in self:
|
||||
repstr += (
|
||||
f'({k},\n'
|
||||
f'\t{repr(left)},\n'
|
||||
f'\t{repr(right)},\n'
|
||||
')\n'
|
||||
)
|
||||
repstr += ']\n'
|
||||
return repstr
|
||||
|
||||
|
||||
class Struct(
|
||||
_Struct,
|
||||
|
||||
# https://jcristharif.com/msgspec/structs.html#tagged-unions
|
||||
# tag='pikerstruct',
|
||||
# tag=True,
|
||||
):
|
||||
'''
|
||||
A "human friendlier" (aka repl buddy) struct subtype.
|
||||
|
||||
'''
|
||||
def _sin_props(self) -> Iterator[
|
||||
tuple[
|
||||
structs.FieldIinfo,
|
||||
str,
|
||||
Any,
|
||||
]
|
||||
]:
|
||||
'''
|
||||
Iterate over all non-@property fields of this struct.
|
||||
|
||||
'''
|
||||
fi: structs.FieldInfo
|
||||
for fi in structs.fields(self):
|
||||
key: str = fi.name
|
||||
val: Any = getattr(self, key)
|
||||
yield fi, key, val
|
||||
|
||||
def to_dict(
|
||||
self,
|
||||
include_non_members: bool = True,
|
||||
|
||||
) -> dict:
|
||||
'''
|
||||
Like it sounds.. direct delegation to:
|
||||
https://jcristharif.com/msgspec/api.html#msgspec.structs.asdict
|
||||
|
||||
BUT, by default we pop all non-member (aka not defined as
|
||||
struct fields) fields by default.
|
||||
|
||||
'''
|
||||
asdict: dict = structs.asdict(self)
|
||||
if include_non_members:
|
||||
return asdict
|
||||
|
||||
# only return a dict of the struct members
|
||||
# which were provided as input, NOT anything
|
||||
# added as type-defined `@property` methods!
|
||||
sin_props: dict = {}
|
||||
fi: structs.FieldInfo
|
||||
for fi, k, v in self._sin_props():
|
||||
sin_props[k] = asdict[k]
|
||||
|
||||
return sin_props
|
||||
|
||||
def pformat(
|
||||
self,
|
||||
field_indent: int = 2,
|
||||
indent: int = 0,
|
||||
|
||||
) -> str:
|
||||
'''
|
||||
Recursion-safe `pprint.pformat()` style formatting of
|
||||
a `msgspec.Struct` for sane reading by a human using a REPL.
|
||||
|
||||
'''
|
||||
# global whitespace indent
|
||||
ws: str = ' '*indent
|
||||
|
||||
# field whitespace indent
|
||||
field_ws: str = ' '*(field_indent + indent)
|
||||
|
||||
# qtn: str = ws + self.__class__.__qualname__
|
||||
qtn: str = self.__class__.__qualname__
|
||||
|
||||
obj_str: str = '' # accumulator
|
||||
fi: structs.FieldInfo
|
||||
k: str
|
||||
v: Any
|
||||
for fi, k, v in self._sin_props():
|
||||
|
||||
# TODO: how can we prefer `Literal['option1', 'option2,
|
||||
# ..]` over .__name__ == `Literal` but still get only the
|
||||
# latter for simple types like `str | int | None` etc..?
|
||||
ft: type = fi.type
|
||||
typ_name: str = getattr(ft, '__name__', str(ft))
|
||||
|
||||
# recurse to get sub-struct's `.pformat()` output Bo
|
||||
if isinstance(v, Struct):
|
||||
val_str: str = v.pformat(
|
||||
indent=field_indent + indent,
|
||||
field_indent=indent + field_indent,
|
||||
)
|
||||
|
||||
else: # the `pprint` recursion-safe format:
|
||||
# https://docs.python.org/3.11/library/pprint.html#pprint.saferepr
|
||||
val_str: str = saferepr(v)
|
||||
|
||||
obj_str += (field_ws + f'{k}: {typ_name} = {val_str},\n')
|
||||
|
||||
return (
|
||||
f'{qtn}(\n'
|
||||
f'{obj_str}'
|
||||
f'{ws})'
|
||||
)
|
||||
|
||||
# TODO: use a pprint.PrettyPrinter instance around ONLY rendering
|
||||
# inside a known tty?
|
||||
# def __repr__(self) -> str:
|
||||
# ...
|
||||
|
||||
# __str__ = __repr__ = pformat
|
||||
__repr__ = pformat
|
||||
|
||||
def copy(
|
||||
self,
|
||||
update: dict | None = None,
|
||||
|
||||
) -> Struct:
|
||||
'''
|
||||
Validate-typecast all self defined fields, return a copy of
|
||||
us with all such fields.
|
||||
|
||||
NOTE: This is kinda like the default behaviour in
|
||||
`pydantic.BaseModel` except a copy of the object is
|
||||
returned making it compat with `frozen=True`.
|
||||
|
||||
'''
|
||||
if update:
|
||||
for k, v in update.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
# NOTE: roundtrip serialize to validate
|
||||
# - enode to msgpack binary format,
|
||||
# - decode that back to a struct.
|
||||
return msgpack.Decoder(type=type(self)).decode(
|
||||
msgpack.Encoder().encode(self)
|
||||
)
|
||||
|
||||
def typecast(
|
||||
self,
|
||||
|
||||
# TODO: allow only casting a named subset?
|
||||
# fields: set[str] | None = None,
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
Cast all fields using their declared type annotations
|
||||
(kinda like what `pydantic` does by default).
|
||||
|
||||
NOTE: this of course won't work on frozen types, use
|
||||
``.copy()`` above in such cases.
|
||||
|
||||
'''
|
||||
# https://jcristharif.com/msgspec/api.html#msgspec.structs.fields
|
||||
fi: structs.FieldInfo
|
||||
for fi in structs.fields(self):
|
||||
setattr(
|
||||
self,
|
||||
fi.name,
|
||||
fi.type(getattr(self, fi.name)),
|
||||
)
|
||||
|
||||
def __sub__(
|
||||
self,
|
||||
other: Struct,
|
||||
|
||||
) -> DiffDump[tuple[str, Any, Any]]:
|
||||
'''
|
||||
Compare fields/items key-wise and return a ``DiffDump``
|
||||
for easy visual REPL comparison B)
|
||||
|
||||
'''
|
||||
diffs: DiffDump[tuple[str, Any, Any]] = DiffDump()
|
||||
for fi in structs.fields(self):
|
||||
attr_name: str = fi.name
|
||||
ours: Any = getattr(self, attr_name)
|
||||
theirs: Any = getattr(other, attr_name)
|
||||
if ours != theirs:
|
||||
diffs.append((
|
||||
attr_name,
|
||||
ours,
|
||||
theirs,
|
||||
))
|
||||
|
||||
return diffs
|
||||
|
|
|
|||
|
|
@ -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 <fqme>.<providername>
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ from . import _style
|
|||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._chart import GodWidget
|
||||
from ._widget import GodWidget
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
|
|
|||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
'''
|
||||
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 <fqme>.<providername>
|
||||
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()
|
||||
|
||||
|
||||
|
|
@ -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__)
|
||||
|
|
|
|||
Loading…
Reference in New Issue