diff --git a/piker/ui/_display.py b/piker/ui/_display.py index 8f050600..06afd142 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -320,6 +320,41 @@ async def increment_history_view( profiler.finish() +def _register_gap_overlays( + dss: dict[str, DisplayState], + godwidget: GodWidget, + +) -> GapOverlayMngr: + ''' + Register display states and start local history gap overlays. + + ''' + from . import _remote_ctl + + _remote_ctl._dss.update(dss) + gapman: GapOverlayMngr = _remote_ctl._gapman + godwidget.gapman = gapman + gap_timeframe: float = HIST_GAP_TIMEFRAME + + # TODO: expose startup visibility and enabled timeframes through + # the UI config once chart periods are configurable. + fqme: str + ds: DisplayState + for fqme, ds in dss.items(): + gap: GapOverlay = gapman.refresh( + ds=ds, + timeframe=gap_timeframe, + ) + log.info( + 'Chart-local gap overlay ready:\n' + f'fqme: {fqme}\n' + f'timeframe: {gap_timeframe}s\n' + f'gaps: {gap.gap_count}\n' + ) + + return gapman + + async def graphics_update_loop( dss: dict[str, DisplayState], nurse: trio.Nursery, @@ -490,27 +525,10 @@ async def graphics_update_loop( # XXX TODO: we need to do _dss UPDATE here so that when # a feed-view is switched you can still remote annotate the # prior view.. - from . import _remote_ctl - _remote_ctl._dss.update(dss) - gapman: GapOverlayMngr = _remote_ctl._gapman - godwidget.gapman = gapman - gap_timeframe: float = HIST_GAP_TIMEFRAME - - # TODO: expose startup visibility and enabled timeframes through - # the UI config once chart periods are configurable. - fqme: str - ds: DisplayState - for fqme, ds in dss.items(): - gap: GapOverlay = gapman.refresh( - ds=ds, - timeframe=gap_timeframe, - ) - log.info( - 'Chart-local gap overlay ready:\n' - f'fqme: {fqme}\n' - f'timeframe: {gap_timeframe}s\n' - f'gaps: {gap.gap_count}\n' - ) + _register_gap_overlays( + dss=dss, + godwidget=godwidget, + ) # main real-time quotes update loop stream: tractor.MsgStream diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index b680bdcb..9e4ce0e4 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -48,7 +48,6 @@ import trio from piker.ui.qt import ( QWheelEvent, - QGraphicsSceneMouseEvent as gs_mouse, Qt, QEvent, ) @@ -108,6 +107,43 @@ ORDER_MODE = { } +def _toggle_gap_overlays( + view: ChartView, + dss: dict[str, DisplayState], + gapman: GapOverlayMngr, + +) -> list[GapOverlay]: + ''' + Toggle gap layers sharing the focused chart and timeframe. + + ''' + gaps: list[GapOverlay] = [] + fqme: str + ds: DisplayState + for fqme, ds in dss.items(): + if view._chart is ds.hist_chart: + timeframe: float = HIST_GAP_TIMEFRAME + elif view._chart is ds.chart: + timeframe = RT_GAP_TIMEFRAME + else: + continue + + gap: GapOverlay = gapman.toggle( + ds=ds, + timeframe=timeframe, + ) + gaps.append(gap) + log.info( + 'Toggled chart-local gap overlay:\n' + f'fqme: {fqme}\n' + f'timeframe: {timeframe}s\n' + f'visible: {gap.visible}\n' + f'gaps: {gap.gap_count}\n' + ) + + return gaps + + async def handle_viewmode_kb_inputs( view: ChartView, @@ -154,7 +190,7 @@ async def handle_viewmode_kb_inputs( shift: bool = False # press branch - if etype in {QEvent.KeyPress}: + if etype in {QEvent.Type.KeyPress}: pressed.add(key) @@ -184,10 +220,10 @@ async def handle_viewmode_kb_inputs( log.debug(f'fast keys seqs {fast_key_seq}') # mods run through - if mods == Qt.ShiftModifier: + if mods == Qt.KeyboardModifier.ShiftModifier: shift = True - if mods == Qt.ControlModifier: + if mods == Qt.KeyboardModifier.ControlModifier: ctrl = True # UI REPL-shell, with ctrl-p (for "pause") @@ -195,7 +231,7 @@ async def handle_viewmode_kb_inputs( ctrl and key in { - Qt.Key_P, + Qt.Key.Key_P, } ): feed = order_mode.feed # noqa @@ -213,7 +249,7 @@ async def handle_viewmode_kb_inputs( ctrl and key in { - Qt.Key_R, + Qt.Key.Key_R, } ): fqme: str @@ -236,29 +272,16 @@ async def handle_viewmode_kb_inputs( if ( ctrl and - key == Qt.Key_G + key == Qt.Key.Key_G ): gapman: GapOverlayMngr|None = godw.gapman if gapman is not None: - fqme: str - ds: DisplayState - for fqme, ds in dss.items(): - timeframe: float = ( - HIST_GAP_TIMEFRAME - if view._chart is ds.hist_chart - else RT_GAP_TIMEFRAME - ) - gap: GapOverlay = gapman.toggle( - ds=ds, - timeframe=timeframe, - ) - log.info( - 'Toggled chart-local gap overlay:\n' - f'fqme: {fqme}\n' - f'timeframe: {timeframe}s\n' - f'visible: {gap.visible}\n' - f'gaps: {gap.gap_count}\n' - ) + _toggle_gap_overlays( + view=view, + dss=dss, + gapman=gapman, + ) + continue # ------ - ------ # SEARCH MODE @@ -363,7 +386,7 @@ async def handle_viewmode_kb_inputs( fast_key_seq.clear() # release branch - elif etype in {QEvent.KeyRelease}: + elif etype in {QEvent.Type.KeyRelease}: if on_next_release: on_next_release() @@ -675,8 +698,8 @@ class ChartView(ViewBox): _event.open_handlers( [self], event_types={ - QEvent.KeyPress, - QEvent.KeyRelease, + QEvent.Type.KeyPress, + QEvent.Type.KeyRelease, }, async_handler=partial( handle_viewmode_kb_inputs, @@ -686,7 +709,7 @@ class ChartView(ViewBox): _event.open_handlers( [self], event_types={ - gs_mouse.GraphicsSceneMousePress, + QEvent.Type.GraphicsSceneMousePress, }, async_handler=partial( handle_viewmode_mouse, diff --git a/tests/test_gap_overlays.py b/tests/test_gap_overlays.py index c37c9c8a..393cb247 100644 --- a/tests/test_gap_overlays.py +++ b/tests/test_gap_overlays.py @@ -10,11 +10,15 @@ os.environ['QT_QPA_PLATFORM'] = 'offscreen' import msgspec import numpy as np +from PyQt6.QtGui import QKeyEvent from PyQt6.QtWidgets import QGraphicsScene import pyqtgraph as pg import pytest +import trio +from trio.testing import wait_all_tasks_blocked from piker.ui._annotate import GapAnnotations +from piker.ui._display import _register_gap_overlays from piker.ui._gaps import ( GapOverlay, GapOverlayMngr, @@ -23,10 +27,17 @@ from piker.ui._gaps import ( SetGapOverlay, gap_specs_from_ohlcv, ) +from piker.ui._interaction import ( + ChartView, + _toggle_gap_overlays, +) from piker.ui.qt import ( QApplication, + QEvent, QPointF, QRectF, + Qt, + QWidget, ) @@ -521,3 +532,198 @@ def test_duplicate_fqme_layers_use_local_chart_identity( for chart in (*first_charts, *second_charts): chart.close() qapp.processEvents() + + +def test_startup_and_focused_toggle_use_real_qt( + qapp: QApplication, + monkeypatch: pytest.MonkeyPatch, + +) -> None: + ''' + Register history gaps and toggle the focused chart only. + + Startup previously lived inside the quote loop and keyboard + routing treated every state unrelated to the focused history + chart as a realtime target. Two independent chart pairs reproduce + that skew. The startup helper must create only 60-second layers; + then a focused history toggle must hide only its own layer while + leaving the other cached chart's overlay intact. + + ''' + from piker.ui import _remote_ctl + + first_ds: SimpleNamespace + first_charts: tuple[_ChartStub, _ChartStub] + first_ds, first_charts = _display_state('first.test') + second_ds: SimpleNamespace + second_charts: tuple[_ChartStub, _ChartStub] + second_ds, second_charts = _display_state('second.test') + dss: dict[str, SimpleNamespace] = { + first_ds.fqme: first_ds, + second_ds.fqme: second_ds, + } + annots: dict[int, GapAnnotations] = {} + gapman: GapOverlayMngr = GapOverlayMngr( + dss={}, + annots=annots, + ) + remote_dss: dict[str, SimpleNamespace] = {} + monkeypatch.setattr(_remote_ctl, '_dss', remote_dss) + monkeypatch.setattr(_remote_ctl, '_annots', annots) + monkeypatch.setattr(_remote_ctl, '_gapman', gapman) + godwidget: SimpleNamespace = SimpleNamespace(gapman=None) + + try: + started: GapOverlayMngr = _register_gap_overlays( + dss=dss, + godwidget=godwidget, + ) + assert started is gapman + assert godwidget.gapman is gapman + assert remote_dss == dss + assert len(gapman._layers) == 2 + key: tuple[str, int, str, float] + for key in gapman._layers: + assert key[3] == 60 + + view: SimpleNamespace = SimpleNamespace( + _chart=first_ds.hist_chart, + ) + toggled: list[GapOverlay] = _toggle_gap_overlays( + view=view, + dss=dss, + gapman=gapman, + ) + assert len(toggled) == 1 + assert toggled[0].fqme == first_ds.fqme + assert toggled[0].timeframe == 60 + assert toggled[0].visible is False + assert ( + 'chart-local', + id(first_ds.hist_chart), + first_ds.fqme, + 60, + ) not in gapman._layers + assert ( + 'chart-local', + id(second_ds.hist_chart), + second_ds.fqme, + 60, + ) in gapman._layers + + view._chart = first_ds.chart + rt_toggled: list[GapOverlay] = _toggle_gap_overlays( + view=view, + dss=dss, + gapman=gapman, + ) + assert len(rt_toggled) == 1 + assert rt_toggled[0].fqme == first_ds.fqme + assert rt_toggled[0].timeframe == 1 + assert rt_toggled[0].visible is True + assert ( + 'chart-local', + id(first_ds.chart), + first_ds.fqme, + 1, + ) in gapman._layers + + unrelated: list[GapOverlay] = _toggle_gap_overlays( + view=SimpleNamespace(_chart=object()), + dss=dss, + gapman=gapman, + ) + assert unrelated == [] + finally: + gapman.remove_owner('chart-local') + chart: _ChartStub + for chart in (*first_charts, *second_charts): + chart.close() + qapp.processEvents() + + +def test_ctrl_g_event_renders_real_history_overlay( + qapp: QApplication, + +) -> None: + ''' + Route a real Qt Ctrl-G event into a rendered history gap layer. + + Calling the toggle helper directly does not prove that Qt event + filtering, `KeyboardMsg` conversion or the asynchronous view-mode + handler recognizes the configured binding. Install the production + `EventRelay` on a real widget, send one offscreen `QKeyEvent`, + and wait until the handler blocks again. The resulting manager + and scene state prove the complete local keyboard path ran. + + ''' + ds: SimpleNamespace + charts: tuple[_ChartStub, _ChartStub] + ds, charts = _display_state(FQME) + annots: dict[int, GapAnnotations] = {} + gapman: GapOverlayMngr = GapOverlayMngr( + dss={FQME: ds}, + annots=annots, + ) + nav: SimpleNamespace = SimpleNamespace( + hide_info=lambda: None, + ) + lines: SimpleNamespace = SimpleNamespace( + unstage_line=lambda: None, + ) + godwidget: SimpleNamespace = SimpleNamespace(gapman=gapman) + order_mode: SimpleNamespace = SimpleNamespace( + godw=godwidget, + cancel_all_orders=lambda: None, + current_pp=SimpleNamespace(nav=nav), + lines=lines, + active=False, + ) + source: QWidget = QWidget() + source.order_mode = order_mode + source._chart = ds.hist_chart + source.linked = SimpleNamespace( + cursor=SimpleNamespace(in_query_mode=False), + ) + source.setMouseMode = lambda mode: None + + async def drive_key_event() -> None: + ''' + Run the production input context around one key event. + + ''' + async with ChartView.open_async_input_handler( + source, + dss={FQME: ds}, + ): + key_event: QKeyEvent = QKeyEvent( + QEvent.Type.KeyPress, + Qt.Key.Key_G, + Qt.KeyboardModifier.ControlModifier, + 'g', + ) + assert QApplication.sendEvent(source, key_event) + await wait_all_tasks_blocked() + + try: + trio.run(drive_key_event) + assert len(gapman._layers) == 1 + layer_key: tuple[str, int, str, float] = next( + iter(gapman._layers) + ) + assert layer_key == ( + 'chart-local', + id(ds.hist_chart), + FQME, + 60, + ) + aid: int = gapman._layers[layer_key] + assert annots[aid].scene() is charts[1].widget.scene() + finally: + gapman.remove_owner('chart-local') + source.close() + source.deleteLater() + chart: _ChartStub + for chart in charts: + chart.close() + qapp.processEvents()