Fix focused gap routing in `ChartView`
The keyboard loop treated every display not owning the focused history chart as a realtime target. One `Ctrl+G` could therefore toggle unrelated cached symbols. Match the focused chart explicitly before selecting its timeframe. Factor startup registration and toggle routing into testable helpers, and use scoped PyQt6 enums throughout the input path. Deats, - preserve independent 60s startup layers per display; - toggle only the focused 1s or 60s chart; - drive the production event relay with a real `QKeyEvent`. (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))chart_local_gapper
parent
711666f9c8
commit
b738c9f4f7
|
|
@ -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,26 +525,9 @@ 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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue