diff --git a/.gitignore b/.gitignore index 722183a2..a591abc4 100644 --- a/.gitignore +++ b/.gitignore @@ -134,3 +134,6 @@ dmypy.json *.orig junit-results.xml .vscode/ + +# Claude +.claude/ \ No newline at end of file diff --git a/src/mne_qt_browser/_pg_figure.py b/src/mne_qt_browser/_pg_figure.py index 13b89aad..1c864038 100644 --- a/src/mne_qt_browser/_pg_figure.py +++ b/src/mne_qt_browser/_pg_figure.py @@ -32,6 +32,15 @@ from mne.utils import _check_option, check_version, get_config, logger, sizeof_fmt, warn from mne.viz._figure import BrowserBase from mne.viz.backends._utils import _init_mne_qtapp, _qt_raise_window +from mne.viz.ui_events import ( + ChannelsSelect, + TimeBrowse, + TimeChange, + disable_ui_events, + publish, + subscribe, + unsubscribe, +) from mne.viz.utils import _merge_annotations, _simplify_float from packaging.version import parse from pyqtgraph import ( @@ -877,6 +886,31 @@ def __init__(self, **kwargs): # disable histogram of epoch PTP amplitude del self.mne.keyboard_shortcuts["h"] + # Subscribe to UI events for cross-figure syncing + subscribe(self, "time_change", self._on_time_change_event) + subscribe(self, "time_browse", self._on_time_browse_event) + subscribe(self, "channels_select", self._on_channels_select_event) + + def _on_time_change_event(self, event): + """Response to TimeChange event from the ui_events system.""" + with disable_ui_events(self): + self._add_vline(event.time) + + def _on_time_browse_event(self, event): + """Response to TimeBrowse event from the ui_events system.""" + with disable_ui_events(self): + self.mne.plt.setXRange(event.time_start, event.time_end, padding=0) + + def _on_channels_select_event(self, event): + """Response to ChannelsSelect event from the ui_events system.""" + all_channels = self.mne.ch_names[self.mne.ch_order] + ch_indices = np.where(np.isin(all_channels, event.ch_names))[0] + if len(ch_indices) == 0: + return + with disable_ui_events(self): + start_idx, end_idx = ch_indices.min(), ch_indices.max() + 2 + self.mne.plt.setYRange(start_idx, end_idx, padding=0) + def _save_setting(self, key, value): """Save a setting to QSettings.""" QSettings("mne-tools", "mne-qt-browser").setValue(key, value) @@ -1131,6 +1165,10 @@ def _vline_slot(self, orig_vline): vl.setPos(xt) self.mne.overview_bar.update_vline() + def _vline_drag_slot(self, vline): + """Publish TimeChange on vline drag for cross-figure syncing.""" + publish(self, TimeChange(time=vline.value())) + def _add_vline(self, t): if self.mne.is_epochs: ts = self._get_vline_times(t) @@ -1148,8 +1186,8 @@ def _add_vline(self, t): # Avoid off-by-one-error at bmax for VlineLabel bmax -= 1 / self.mne.info["sfreq"] vl = VLine(self.mne, xt, bounds=(bmin, bmax)) - # Should only be emitted when dragged vl.sigPositionChangeFinished.connect(self._vline_slot) + vl.sigDragged.connect(self._vline_drag_slot) self.mne.vline.append(vl) self.mne.plt.addItem(vl) else: @@ -1159,12 +1197,14 @@ def _add_vline(self, t): if self.mne.vline is None: self.mne.vline = VLine(self.mne, t, bounds=(0, self.mne.xmax)) self.mne.vline.sigPositionChangeFinished.connect(self._vline_slot) + self.mne.vline.sigDragged.connect(self._vline_drag_slot) self.mne.plt.addItem(self.mne.vline) else: self.mne.vline.setPos(t) self.mne.vline_visible = True self.mne.overview_bar.update_vline() + publish(self, TimeChange(time=t)) def _mouse_moved(self, pos): """Show crosshair if enabled at mouse move.""" @@ -1264,6 +1304,15 @@ def _xrange_changed(self, _, xrange): # Update annotations self._update_regions_visible() + # Publish event for cross-figure syncing + publish( + self, + TimeBrowse( + time_start=self.mne.t_start, + time_end=self.mne.t_start + self.mne.duration, + ), + ) + def _yrange_changed(self, _, yrange): if not self.mne.butterfly: if not self.mne.fig_selection: @@ -1317,6 +1366,9 @@ def _yrange_changed(self, _, yrange): trace.update_color() trace.update_data() + # Publish event for cross-figure syncing + publish(self, ChannelsSelect(ch_names=self.mne.ch_names[self.mne.picks])) + # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # DATA HANDLING # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # @@ -2200,6 +2252,7 @@ def _check_close(self): def closeEvent(self, event): """Customize close event.""" event.accept() + unsubscribe(self, ["time_change", "time_browse", "channels_select"]) if hasattr(self, "mne"): # Explicit disconnects to avoid reference cycles that GC can't properly # resolve diff --git a/tests/test_ui_events.py b/tests/test_ui_events.py new file mode 100644 index 00000000..41149442 --- /dev/null +++ b/tests/test_ui_events.py @@ -0,0 +1,142 @@ +# License: BSD-3-Clause +# Copyright the MNE Qt Browser contributors. + +from mne.viz import ui_events +from numpy.testing import assert_allclose + + +def test_time_change_publishes_on_vline(raw_orig, pg_backend): + """Test that adding a vline publishes a TimeChange event.""" + raw = raw_orig.copy().crop(tmax=10.0).resample(100) + fig = raw.plot() + fig.test_mode = True + + callback_calls = [] + + def callback(event): + callback_calls.append(event) + + ui_events.subscribe(fig, "time_change", callback) + + fig._add_vline(2.0) + assert len(callback_calls) >= 1 + assert isinstance(callback_calls[-1], ui_events.TimeChange) + assert_allclose(callback_calls[-1].time, 2.0, atol=0.01) + + fig.close() + + +def test_time_change_linked_figures(raw_orig, pg_backend): + """Test that linking two figures syncs vlines via TimeChange.""" + raw = raw_orig.copy().crop(tmax=10.0).resample(100) + fig1 = raw.plot() + fig2 = raw.plot() + fig1.test_mode = True + fig2.test_mode = True + + ui_events.link(fig1, fig2) + + fig1._add_vline(3.0) + assert fig2.mne.vline is not None + + fig1.close() + fig2.close() + + +def test_time_browse_publishes_on_scroll(raw_orig, pg_backend): + """Test that scrolling time publishes a TimeBrowse event.""" + raw = raw_orig.copy().crop(tmax=20.0).resample(100) + fig = raw.plot(duration=5.0) + fig.test_mode = True + + callback_calls = [] + + def callback(event): + callback_calls.append(event) + + ui_events.subscribe(fig, "time_browse", callback) + + fig.mne.plt.setXRange(2.0, 7.0, padding=0) + assert len(callback_calls) >= 1 + assert isinstance(callback_calls[-1], ui_events.TimeBrowse) + + fig.close() + + +def test_time_browse_linked_figures(raw_orig, pg_backend): + """Test that linking two figures syncs time browsing.""" + raw = raw_orig.copy().crop(tmax=20.0).resample(100) + fig1 = raw.plot(duration=5.0) + fig2 = raw.plot(duration=5.0) + fig1.test_mode = True + fig2.test_mode = True + + ui_events.link(fig1, fig2) + + fig1.mne.plt.setXRange(3.0, 8.0, padding=0) + assert_allclose(fig2.mne.t_start, 3.0, atol=0.5) + + fig1.close() + fig2.close() + + +def test_channels_select_publishes(raw_orig, pg_backend): + """Test that scrolling channels publishes a ChannelsSelect event.""" + raw = raw_orig.copy().crop(tmax=10.0).resample(100) + fig = raw.plot() + fig.test_mode = True + + callback_calls = [] + + def callback(event): + callback_calls.append(event) + + ui_events.subscribe(fig, "channels_select", callback) + + fig.mne.plt.setYRange(2, 8, padding=0) + assert len(callback_calls) >= 1 + assert isinstance(callback_calls[-1], ui_events.ChannelsSelect) + assert hasattr(callback_calls[-1], "ch_names") + + fig.close() + + +def test_disable_ui_events_prevents_feedback(raw_orig, pg_backend): + """Test that disable_ui_events prevents event publishing.""" + raw = raw_orig.copy().crop(tmax=10.0).resample(100) + fig = raw.plot() + fig.test_mode = True + + callback_calls = [] + + def callback(event): + callback_calls.append(event) + + ui_events.subscribe(fig, "time_change", callback) + + with ui_events.disable_ui_events(fig): + fig._add_vline(2.0) + + assert len(callback_calls) == 0 + + fig.close() + + +def test_close_unsubscribes(raw_orig, pg_backend): + """Test that closing figure properly unsubscribes from events.""" + raw = raw_orig.copy().crop(tmax=10.0).resample(100) + fig = raw.plot() + fig.test_mode = True + + channel = ui_events._get_event_channel(fig) + assert "time_change" in channel + assert "time_browse" in channel + assert "channels_select" in channel + + fig.close() + # After close, the event channel should have been cleaned up + # by the unsubscribe call in closeEvent + channel = ui_events._get_event_channel(fig) + assert "time_change" not in channel + assert "time_browse" not in channel + assert "channels_select" not in channel