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
Gud Boi 2026-08-17 19:09:46 -04:00
parent 711666f9c8
commit b738c9f4f7
3 changed files with 298 additions and 51 deletions

View File

@ -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

View File

@ -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,

View File

@ -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()