From 711666f9c831f9ae46b3a615c7f4c39beb1de140 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 17 Aug 2026 19:08:28 -0400 Subject: [PATCH] Fix Qt indexing in `GapAnnotations.reposition()` `prepareGeometryChange()` ran after the gap rects and arrow specs were mutated. Qt could retain the item's old scene index even though `boundingRect()` returned its new prepended-history position. Invalidate cached bounds and notify Qt before changing any geometry. Cover both scene lookup positions with a real offscreen item. (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`)) --- piker/ui/_annotate.py | 8 ++-- tests/test_gap_overlays.py | 80 +++++++++++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/piker/ui/_annotate.py b/piker/ui/_annotate.py index a6147587..8e930d88 100644 --- a/piker/ui/_annotate.py +++ b/piker/ui/_annotate.py @@ -670,6 +670,11 @@ class GapAnnotations(GraphicsObject): ) } + # Notify Qt before mutating anything used by + # `boundingRect()`. + self.prepareGeometryChange() + self._br = None + # rebuild rect array from gap specs with new indices rect_memory = self._rectarray.ndarray() @@ -777,7 +782,4 @@ class GapAnnotations(GraphicsObject): self._arrow_path.addPolygon(arrow_poly) self._arrow_path.closeSubpath() - # invalidate bounding rect cache - self._br = None - self.prepareGeometryChange() self.update() diff --git a/tests/test_gap_overlays.py b/tests/test_gap_overlays.py index 6d940449..c37c9c8a 100644 --- a/tests/test_gap_overlays.py +++ b/tests/test_gap_overlays.py @@ -10,6 +10,7 @@ os.environ['QT_QPA_PLATFORM'] = 'offscreen' import msgspec import numpy as np +from PyQt6.QtWidgets import QGraphicsScene import pyqtgraph as pg import pytest @@ -22,7 +23,11 @@ from piker.ui._gaps import ( SetGapOverlay, gap_specs_from_ohlcv, ) -from piker.ui.qt import QApplication +from piker.ui.qt import ( + QApplication, + QPointF, + QRectF, +) FQME: str = 'gap.test' @@ -387,6 +392,79 @@ def test_gap_manager_real_qt_lifecycle( qapp.processEvents() +def test_gap_annotations_reposition_after_prepend( + qapp: QApplication, + +) -> None: + ''' + Reposition live gap geometry after history index offsets change. + + A history prepend preserves timestamps but shifts every absolute + chart index. Gap rectangles and arrows otherwise remain attached + to their pre-prepend x coordinates. Render one real gap item, + shift its source indexes by ten, call `reposition()`, and prove + its rectangle bounds and arrow spec move together while the item + stays attached to its real Qt scene. + + ''' + ds: SimpleNamespace + charts: tuple[_ChartStub, _ChartStub] + ds, charts = _display_state(FQME) + annots: dict[int, GapAnnotations] = {} + gapman: GapOverlayMngr = GapOverlayMngr( + dss={FQME: ds}, + annots=annots, + ) + + try: + gap: GapOverlay = gapman.refresh( + ds=ds, + timeframe=60, + ) + assert gap.aid is not None + item: GapAnnotations = annots[gap.aid] + old_br: QRectF = item.boundingRect() + scene: QGraphicsScene = charts[1].widget.scene() + old_scene_point: QPointF = item.mapToScene( + old_br.center() + ) + qapp.processEvents() + assert item in scene.items(old_scene_point) + old_rects: np.ndarray = ( + item._rectarray.ndarray().copy() + ) + shifted: np.ndarray = ds.hist_viz.shm.array.copy() + shifted['index'] += 10 + + item.reposition( + array=shifted, + fqme=FQME, + timeframe=60, + ) + qapp.processEvents() + + new_rects: np.ndarray = item._rectarray.ndarray() + np.testing.assert_allclose( + new_rects[:, 0], + old_rects[:, 0] + 10, + ) + assert item._gap_specs[0]['arrow_x'] == 12 + new_br: QRectF = item.boundingRect() + new_scene_point: QPointF = item.mapToScene( + new_br.center() + ) + assert new_br.left() == old_br.left() + 10 + assert item in scene.items(new_scene_point) + assert item not in scene.items(old_scene_point) + assert item.scene() is charts[1].widget.scene() + finally: + gapman.remove_owner('chart-local') + chart: _ChartStub + for chart in charts: + chart.close() + qapp.processEvents() + + def test_duplicate_fqme_layers_use_local_chart_identity( qapp: QApplication,