From 7eaf28479c2b8141acada5d74635480e6c438c7f Mon Sep 17 00:00:00 2001 From: goodboy Date: Wed, 21 Jan 2026 20:02:10 -0500 Subject: [PATCH 1/8] Fix `Qt6` types for new sub-namespaces --- piker/ui/_l1.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/piker/ui/_l1.py b/piker/ui/_l1.py index 8d29d90c..f557de4b 100644 --- a/piker/ui/_l1.py +++ b/piker/ui/_l1.py @@ -237,8 +237,8 @@ class LevelLabel(YAxisLabel): class L1Label(LevelLabel): text_flags = ( - QtCore.Qt.TextDontClip - | QtCore.Qt.AlignLeft + QtCore.Qt.TextFlag.TextDontClip + | QtCore.Qt.AlignmentFlag.AlignLeft ) def set_label_str( From bb81c743533c49070ee8b82dbe372a228ea68ca4 Mon Sep 17 00:00:00 2001 From: goodboy Date: Sun, 25 Jan 2026 22:19:39 -0500 Subject: [PATCH 2/8] .ui.order_mode: multiline import styling --- piker/ui/order_mode.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index f1f0e62f..76bee0ef 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -59,8 +59,14 @@ from piker.data import ( from piker.types import Struct from piker.log import get_logger from piker.ui.qt import Qt -from ._editors import LineEditor, ArrowEditor -from ._lines import order_line, LevelLine +from ._editors import ( + LineEditor, + ArrowEditor, +) +from ._lines import ( + order_line, + LevelLine, +) from ._position import ( PositionTracker, SettingsPane, From e8152b85344679b32fddd324bc1318617b9d211d Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 27 Jan 2026 13:34:52 -0500 Subject: [PATCH 3/8] Add a couple cooler "cooler"/"muted" red and greens --- piker/ui/_style.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/piker/ui/_style.py b/piker/ui/_style.py index 196d6fd8..b6c47817 100644 --- a/piker/ui/_style.py +++ b/piker/ui/_style.py @@ -308,6 +308,7 @@ def hcolor(name: str) -> str: 'cool_green': '#33b864', 'dull_green': '#74a662', 'hedge_green': '#518360', + 'lilypad_green': '#839c84', # orders and alerts 'alert_yellow': '#e2d083', @@ -335,6 +336,7 @@ def hcolor(name: str) -> str: 'sell_red': '#b6003f', # 'sell_red': '#d00048', 'sell_red_light': '#f85462', + 'wine': '#69212d', # 'sell_red': '#f85462', # 'sell_red_light': '#ff4d5c', From d6a56d87bf6be93c5f8706f0f96dea09f48ef6ac Mon Sep 17 00:00:00 2001 From: goodboy Date: Fri, 30 Jan 2026 14:58:41 -0500 Subject: [PATCH 4/8] Rm unused import in `.ui._curve` --- piker/ui/_curve.py | 1 - 1 file changed, 1 deletion(-) diff --git a/piker/ui/_curve.py b/piker/ui/_curve.py index dc368344..4a25ae70 100644 --- a/piker/ui/_curve.py +++ b/piker/ui/_curve.py @@ -27,7 +27,6 @@ import pyqtgraph as pg from piker.ui.qt import ( QtWidgets, - QGraphicsItem, Qt, QLineF, QRectF, From 5020266bd5e539707133cbcd3a1d202d8632daf8 Mon Sep 17 00:00:00 2001 From: goodboy Date: Fri, 30 Jan 2026 15:39:25 -0500 Subject: [PATCH 5/8] Add `get_godw()` singleton getter for `GodWidget` Expose `get_godw()` helper to retrieve the central `GodWidget` instance from anywhere in the UI code. Set the singleton in `_async_main()` on startup. Also, - add docstring to `run_qtractor()` explaining trio guest mode - type annotate `instance: GodWidget` in `run_qtractor()` - import reorg in `._app` for cleaner grouping - whitespace cleanup: `Type | None` -> `Type|None` throughout - fix bitwise-or alignment: `Flag | Other` -> `Flag|Other` (this commit-msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- piker/ui/_app.py | 21 ++++++------ piker/ui/_chart.py | 79 ++++++++++++++++++++++++++++----------------- piker/ui/_exec.py | 6 +++- piker/ui/_window.py | 12 +++---- 4 files changed, 72 insertions(+), 46 deletions(-) diff --git a/piker/ui/_app.py b/piker/ui/_app.py index 68ecb3dd..f078163d 100644 --- a/piker/ui/_app.py +++ b/piker/ui/_app.py @@ -27,15 +27,15 @@ import trio from piker.ui.qt import ( QEvent, ) -from ..service import maybe_spawn_brokerd +from . import _chart from . import _event -from ._exec import run_qtractor -from ..data.feed import install_brokerd_search -from ..data._symcache import open_symcache -from ..accounting import unpack_fqme from . import _search -from ._chart import GodWidget +from ..accounting import unpack_fqme +from ..data._symcache import open_symcache +from ..data.feed import install_brokerd_search from ..log import get_logger +from ..service import maybe_spawn_brokerd +from ._exec import run_qtractor log = get_logger(__name__) @@ -73,8 +73,8 @@ async def load_provider_search( async def _async_main( - # implicit required argument provided by ``qtractor_run()`` - main_widget: GodWidget, + # implicit required argument provided by `qtractor_run()` + main_widget: _chart.GodWidget, syms: list[str], brokers: dict[str, ModuleType], @@ -87,6 +87,9 @@ async def _async_main( Provision the "main" widget with initial symbol data and root nursery. """ + # set as singleton + _chart._godw = main_widget + from . import _display from ._pg_overrides import _do_overrides _do_overrides() @@ -201,6 +204,6 @@ def _main( brokermods, piker_loglevel, ), - main_widget_type=GodWidget, + main_widget_type=_chart.GodWidget, tractor_kwargs=tractor_kwargs, ) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 98b25398..270255fc 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -82,6 +82,25 @@ if TYPE_CHECKING: 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 @@ -104,7 +123,7 @@ class GodWidget(QWidget): super().__init__(parent) - self.search: SearchWidget | None = None + self.search: SearchWidget|None = None self.hbox = QHBoxLayout(self) self.hbox.setContentsMargins(0, 0, 0, 0) @@ -123,9 +142,9 @@ class GodWidget(QWidget): tuple[LinkedSplits, LinkedSplits], ] = {} - self.hist_linked: LinkedSplits | None = None - self.rt_linked: LinkedSplits | None = None - self._active_cursor: Cursor | None = None + 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 @@ -369,9 +388,9 @@ class ChartnPane(QFrame): https://doc.qt.io/qt-5/qwidget.html#composite-widgets ''' - sidepane: FieldsForm | SearchWidget + sidepane: FieldsForm|SearchWidget hbox: QHBoxLayout - chart: ChartPlotWidget | None = None + chart: ChartPlotWidget|None = None def __init__( self, @@ -387,13 +406,13 @@ class ChartnPane(QFrame): self.chart = None hbox = self.hbox = QHBoxLayout(self) - hbox.setAlignment(Qt.AlignTop | Qt.AlignLeft) + hbox.setAlignment(Qt.AlignTop|Qt.AlignLeft) hbox.setContentsMargins(0, 0, 0, 0) hbox.setSpacing(3) def set_sidepane( self, - sidepane: FieldsForm | SearchWidget, + sidepane: FieldsForm|SearchWidget, ) -> None: # add sidepane **after** chart; place it on axis side @@ -404,7 +423,7 @@ class ChartnPane(QFrame): self._sidepane = sidepane @property - def sidepane(self) -> FieldsForm | SearchWidget: + def sidepane(self) -> FieldsForm|SearchWidget: return self._sidepane @@ -450,7 +469,7 @@ class LinkedSplits(QWidget): # chart-local graphics state that can be passed to # a ``graphic_update_cycle()`` call by any task wishing to # update the UI for a given "chart instance". - self.display_state: DisplayState | None = None + self.display_state: DisplayState|None = None self._mkt: MktPair = None @@ -486,7 +505,7 @@ class LinkedSplits(QWidget): def set_split_sizes( self, - prop: float | None = None, + prop: float|None = None, ) -> None: ''' @@ -567,8 +586,8 @@ class LinkedSplits(QWidget): # style? self.chart.setFrameStyle( - QFrame.Shape.StyledPanel | - QFrame.Shadow.Plain + QFrame.Shape.StyledPanel + |QFrame.Shadow.Plain ) return self.chart @@ -580,11 +599,11 @@ class LinkedSplits(QWidget): shm: ShmArray, flume: Flume, - array_key: str | None = None, + array_key: str|None = None, style: str = 'line', _is_main: bool = False, - sidepane: QWidget | None = None, + sidepane: QWidget|None = None, draw_kwargs: dict = {}, **cpw_kwargs, @@ -687,7 +706,7 @@ class LinkedSplits(QWidget): cpw.plotItem.vb.linked = self cpw.setFrameStyle( QFrame.Shape.StyledPanel - # | QFrame.Shadow.Plain + # |QFrame.Shadow.Plain ) # don't show the little "autoscale" A label. @@ -800,7 +819,7 @@ class LinkedSplits(QWidget): def resize_sidepanes( self, - from_linked: LinkedSplits | None = None, + from_linked: LinkedSplits|None = None, ) -> None: ''' @@ -874,7 +893,7 @@ class ChartPlotWidget(pg.PlotWidget): # TODO: load from config use_open_gl: bool = False, - static_yrange: tuple[float, float] | None = None, + static_yrange: tuple[float, float]|None = None, parent=None, **kwargs, @@ -889,7 +908,7 @@ class ChartPlotWidget(pg.PlotWidget): # NOTE: must be set bfore calling ``.mk_vb()`` self.linked = linkedsplits - self.sidepane: FieldsForm | None = None + self.sidepane: FieldsForm|None = None # source of our custom interactions self.cv = self.mk_vb(name) @@ -923,7 +942,7 @@ class ChartPlotWidget(pg.PlotWidget): self.useOpenGL(use_open_gl) self.name = name self.data_key = data_key or name - self.qframe: ChartnPane | None = None + self.qframe: ChartnPane|None = None # scene-local placeholder for book graphics # sizing to avoid overlap with data contents @@ -934,7 +953,7 @@ class ChartPlotWidget(pg.PlotWidget): # registry of overlay curve names self._vizs: dict[str, Viz] = {} - self.feed: Feed | None = None + self.feed: Feed|None = None self._labels = {} # registry of underlying graphics self._ysticks = {} # registry of underlying graphics @@ -1027,7 +1046,7 @@ class ChartPlotWidget(pg.PlotWidget): def increment_view( self, datums: int = 1, - vb: ChartView | None = None, + vb: ChartView|None = None, ) -> None: ''' @@ -1058,8 +1077,8 @@ class ChartPlotWidget(pg.PlotWidget): def overlay_plotitem( self, name: str, - index: int | None = None, - axis_title: str | None = None, + index: int|None = None, + axis_title: str|None = None, axis_side: str = 'right', axis_kwargs: dict = {}, @@ -1147,14 +1166,14 @@ class ChartPlotWidget(pg.PlotWidget): shm: ShmArray, flume: Flume, - array_key: str | None = None, + array_key: str|None = None, overlay: bool = False, - color: str | None = None, + color: str|None = None, add_label: bool = True, - pi: pg.PlotItem | None = None, + pi: pg.PlotItem|None = None, step_mode: bool = False, is_ohlc: bool = False, - add_sticky: None | str = 'right', + add_sticky: None|str = 'right', **graphics_kwargs, @@ -1252,7 +1271,7 @@ class ChartPlotWidget(pg.PlotWidget): # use the tick size precision for display name = name or pi.name mkt: MktPair = self.linked.mkt - digits: int | None = None + digits: int|None = None if name in mkt.fqme: digits = mkt.price_tick_digits @@ -1286,7 +1305,7 @@ class ChartPlotWidget(pg.PlotWidget): shm: ShmArray, flume: Flume, - array_key: str | None = None, + array_key: str|None = None, **draw_curve_kwargs, ) -> Viz: diff --git a/piker/ui/_exec.py b/piker/ui/_exec.py index ba91e534..3643786d 100644 --- a/piker/ui/_exec.py +++ b/piker/ui/_exec.py @@ -91,6 +91,10 @@ def run_qtractor( window_type: QMainWindow = None, ) -> None: + ''' + Run the Qt event loop and embed `trio` via guest mode on it. + + ''' # avoids annoying message when entering debugger from qt loop pyqtRemoveInputHook() @@ -170,7 +174,7 @@ def run_qtractor( # hook into app focus change events app.focusChanged.connect(window.on_focus_change) - instance = main_widget_type() + instance: GodWidget = main_widget_type() instance.window = window # override tractor's defaults diff --git a/piker/ui/_window.py b/piker/ui/_window.py index a15ecd24..12f4209a 100644 --- a/piker/ui/_window.py +++ b/piker/ui/_window.py @@ -61,9 +61,9 @@ class MultiStatus: self, msg: str, - final_msg: str | None = None, + final_msg: str|None = None, clear_on_next: bool = False, - group_key: Union[bool, str] | None = False, + group_key: Union[bool, str]|None = False, ) -> Union[Callable[..., None], str]: ''' @@ -175,11 +175,11 @@ class MainWindow(QMainWindow): self.setWindowTitle(self.title) # set by runtime after `trio` is engaged. - self.godwidget: GodWidget | None = None + self.godwidget: GodWidget|None = None self._status_bar: QStatusBar = None self._status_label: QLabel = None - self._size: tuple[int, int] | None = None + self._size: tuple[int, int]|None = None @property def mode_label(self) -> QLabel: @@ -202,7 +202,7 @@ class MainWindow(QMainWindow): label.setMargin(2) label.setAlignment( QtCore.Qt.AlignVCenter - | QtCore.Qt.AlignRight + |QtCore.Qt.AlignRight ) self.statusBar().addPermanentWidget(label) label.show() @@ -288,7 +288,7 @@ class MainWindow(QMainWindow): def configure_to_desktop( self, - size: tuple[int, int] | None = None, + size: tuple[int, int]|None = None, ) -> None: ''' From ad37ebabb2b65d489309e7c41b471b2017165f02 Mon Sep 17 00:00:00 2001 From: goodboy Date: Sun, 1 Feb 2026 19:28:14 -0500 Subject: [PATCH 6/8] Cleanups and doc tweaks to `.ui._fsp` Expand read-race warning log for clarity, add TODO for reading `tractor` transport config from `conf.toml`, and reflow docstring in `open_vlm_displays()`. Also, - whitespace cleanup: `Type | None` -> `Type|None` - clarify "Volume" -> "Vlm (volume)" in docstr (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- piker/ui/_fsp.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/piker/ui/_fsp.py b/piker/ui/_fsp.py index ca43ed77..3a1a80a5 100644 --- a/piker/ui/_fsp.py +++ b/piker/ui/_fsp.py @@ -73,7 +73,7 @@ log = get_logger(__name__) def update_fsp_chart( viz, graphics_name: str, - array_key: str | None, + array_key: str|None, **kwargs, ) -> None: @@ -87,7 +87,11 @@ def update_fsp_chart( # guard against unreadable case if not last_row: - log.warning(f'Read-race on shm array: {graphics_name}@{shm.token}') + log.warning( + f'Read-race on shm array,\n' + f'graphics_name: {graphics_name!r}\n' + f'shm.token: {shm.token}\n' + ) return # update graphics @@ -203,7 +207,6 @@ async def open_fsp_actor_cluster( async def run_fsp_ui( - linkedsplits: LinkedSplits, flume: Flume, started: trio.Event, @@ -471,7 +474,7 @@ class FspAdmin: target: Fsp, conf: dict[str, dict[str, Any]], - worker_name: str | None = None, + worker_name: str|None = None, loglevel: str = 'info', ) -> (Flume, trio.Event): @@ -623,8 +626,10 @@ async def open_fsp_admin( event.set() +# TODO, passing in `pikerd` related settings here! +# [ ] read in the `tractor` setting for `enable_transports: list` +# from the root `conf.toml`! async def open_vlm_displays( - linked: LinkedSplits, flume: Flume, dvlm: bool = True, @@ -634,12 +639,12 @@ async def open_vlm_displays( ) -> None: ''' - Volume subchart displays. + Vlm (volume) subchart displays. Since "volume" is often included directly alongside OHLCV price - data, we don't really need a separate FSP-actor + shm array for it - since it's likely already directly adjacent to OHLC samples from the - data provider. + data, we don't really need a separate FSP-actor + shm array for + it since it's likely already directly adjacent to OHLC samples + from the data provider. Further only if volume data is detected (it sometimes isn't provided eg. forex, certain commodities markets) will volume dependent FSPs From 8fb47f761ac2d910ad8ca3a5fd2b6be8c6d8fbc4 Mon Sep 17 00:00:00 2001 From: goodboy Date: Wed, 4 Feb 2026 15:57:18 -0500 Subject: [PATCH 7/8] Point `.types.Struct` to `tractor.msg.pretty_struct` Drop the local (and original) `Struct` impl from `piker.types` in favour of `tractor`'s version now that it's been upstreamed. (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- piker/types.py | 228 +------------------------------------------------ 1 file changed, 2 insertions(+), 226 deletions(-) diff --git a/piker/types.py b/piker/types.py index cda3fb44..80ccb26a 100644 --- a/piker/types.py +++ b/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 From 0b63a73954004e2494452ecf1c1274db257e5e7f Mon Sep 17 00:00:00 2001 From: goodboy Date: Wed, 4 Feb 2026 16:14:13 -0500 Subject: [PATCH 8/8] Move `GodWidget` to new `._widget` mod Extract root-most widget to resolve (various) `.ui` import cycles when the type is declared on `Struct`s.. Deats, - flip to `from ._widget import GodWidget`. - move `Feed` + `Flume` imports to TYPE_CHECKING in `._chart` - drop unused `trio` import from `._chart` - fix docstring typo: "datums```" -> "`datums``" - change `print()` to `log.warning()` for global step msg (this commit msg was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- piker/ui/_chart.py | 308 +------------------------------------- piker/ui/_editors.py | 5 + piker/ui/_exec.py | 2 +- piker/ui/_widget.py | 346 +++++++++++++++++++++++++++++++++++++++++++ piker/ui/_window.py | 2 +- 5 files changed, 358 insertions(+), 305 deletions(-) create mode 100644 piker/ui/_widget.py 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 9aba7978..9809ba71 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -54,6 +54,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__)