diff --git a/doc/changes/dev/14040.newfeature.rst b/doc/changes/dev/14040.newfeature.rst new file mode 100644 index 00000000000..0ff7a47e1c6 --- /dev/null +++ b/doc/changes/dev/14040.newfeature.rst @@ -0,0 +1 @@ +Make the vertical (channel) and horizontal (time) scrollbar handles in the ``'matplotlib'`` browser backend draggable, by `Clemens Brunner`_. \ No newline at end of file diff --git a/mne/viz/_mpl_figure.py b/mne/viz/_mpl_figure.py index e4ccde1a209..3246088a9e5 100644 --- a/mne/viz/_mpl_figure.py +++ b/mne/viz/_mpl_figure.py @@ -220,6 +220,14 @@ def _buttonpress(self, event): """Handle buttonpress events.""" pass + def _buttonrelease(self, event): + """Handle button release events.""" + pass + + def _mouse_move(self, event): + """Handle mouse motion events.""" + pass + def _scroll(self, event): """Handle scroll wheel events.""" pass @@ -245,6 +253,8 @@ def _add_default_callbacks(self, **kwargs): resize_event=self._resize, key_press_event=self._keypress, button_press_event=self._buttonpress, + button_release_event=self._buttonrelease, + motion_notify_event=self._mouse_move, scroll_event=self._scroll, close_event=self._close, pick_event=self._pick, @@ -661,6 +671,8 @@ def __init__(self, inst, figsize, ica=None, xlabel="Time (s)", **kwargs): ax_vscroll=ax_vscroll, vsel_patch=vsel_patch, hsel_patch=hsel_patch, + vscroll_drag_offset=None, + hscroll_drag_offset=None, vline=vline, vline_hscroll=vline_hscroll, vline_text=vline_text, @@ -924,12 +936,15 @@ def _buttonpress(self, event): elif event.inaxes == self.mne.ax_vscroll: if self.mne.fig_selection is not None: self._change_selection_vscroll(event) - elif self._check_update_vscroll_clicked(event): - self._redraw() + else: + if self._check_update_vscroll_clicked(event): + self._redraw() + self.mne.vscroll_drag_offset = event.ydata - self.mne.ch_start # click in horizontal scrollbar elif event.inaxes == self.mne.ax_hscroll: if self._check_update_hscroll_clicked(event): self._redraw(annotations=True) + self.mne.hscroll_drag_offset = event.xdata - self.mne.t_start # click on proj button elif event.inaxes == self.mne.ax_proj: self._toggle_proj_fig(event) @@ -971,6 +986,46 @@ def _buttonpress(self, event): elif event.inaxes == ax_main: self._toggle_vline(False) + def _buttonrelease(self, event): + """Handle mouse button releases (end scrollbar handle drags).""" + self.mne.vscroll_drag_offset = None + self.mne.hscroll_drag_offset = None + + def _mouse_move(self, event): + """Handle mouse motion (drag the scrollbar handles).""" + if self.mne.vscroll_drag_offset is not None: + if event.y is None: + return + ydata = self.mne.ax_vscroll.transData.inverted().transform((0, event.y))[1] + new_ch_start = np.clip( + int(round(ydata - self.mne.vscroll_drag_offset)), + 0, + len(self.mne.ch_order) - self.mne.n_channels, + ) + if self.mne.ch_start != new_ch_start: + self.mne.ch_start = new_ch_start + self._update_picks() + self._update_vscroll() + self._redraw() + elif self.mne.hscroll_drag_offset is not None: + if event.x is None: + return + xdata = self.mne.ax_hscroll.transData.inverted().transform((event.x, 0))[0] + time = xdata - self.mne.hscroll_drag_offset + max_time = ( + self.mne.n_times / self.mne.info["sfreq"] + + self.mne.first_time + - self.mne.duration + ) + time = np.clip(time, self.mne.first_time, max_time) + if self.mne.is_epochs: + ix = np.searchsorted(self.mne.boundary_times[1:], time, side="right") + time = self.mne.boundary_times[ix] + if self.mne.t_start != time: + self.mne.t_start = time + self._update_hscroll() + self._redraw(annotations=True) + def _scroll(self, event): """Handle scroll wheel events for channel navigation.""" if self.mne.butterfly or self.mne.fig_selection is not None: diff --git a/mne/viz/tests/test_raw.py b/mne/viz/tests/test_raw.py index ed8f7eb92d3..153caaee19b 100644 --- a/mne/viz/tests/test_raw.py +++ b/mne/viz/tests/test_raw.py @@ -687,15 +687,43 @@ def test_plot_raw_traces(raw, events, browser_backend): fig._fake_click((0.5, 0.05), ax=vscroll) # change channels to end labels = fig._get_ticklabels("y") assert labels == [raw.ch_names[5], raw.ch_names[2], raw.ch_names[3]] - for _ in (0, 0): - # first click changes channels to mid; second time shouldn't change - # This needs to be changed for Qt, because there scrollbars are - # drawn differently (value of slider at lower end, not at middle) + for _ in range(2): # first click jumps to mid, second is a no-op (already there) + # mpl centers the handle on the click; Qt's QScrollBar positions the handle at + # its low end, hence the different target for Qt yclick = 0.5 if ismpl else 0.7 fig._fake_click((0.5, yclick), ax=vscroll) labels = fig._get_ticklabels("y") assert labels == [raw.ch_names[7], raw.ch_names[5], raw.ch_names[2]] + # Qt scrollbars are native QScrollBar widgets, so dragging them is already handled + # by Qt itself; here we only need to test the custom drag handling added for the + # 'matplotlib' scrollbars. + if ismpl: + # dragging the vertical scrollbar handle + n_channels = fig.mne.n_channels + ch_start = fig.mne.ch_start + center = ch_start + n_channels / 2 + fig._fake_click((0.5, center), ax=vscroll, xform="data", kind="press") + fig._fake_click((0.5, center + 1), ax=vscroll, xform="data", kind="motion") + assert fig.mne.ch_start == ch_start + 1 + fig._fake_click((0.5, center + 1), ax=vscroll, xform="data", kind="release") + # further motion after release should be a no-op + fig._fake_click((0.5, center + 5), ax=vscroll, xform="data", kind="motion") + assert fig.mne.ch_start == ch_start + 1 + + # dragging the horizontal scrollbar handle + duration = fig.mne.duration + t_start = fig.mne.t_start + center = t_start + duration / 2 + fig._fake_click((center, 0.5), ax=hscroll, xform="data", kind="press") + fig._fake_click((center + 1, 0.5), ax=hscroll, xform="data", kind="motion") + assert fig.mne.t_start == pytest.approx(t_start + 1, abs=0.05) + fig._fake_click((center + 1, 0.5), ax=hscroll, xform="data", kind="release") + dragged_t_start = fig.mne.t_start + # further motion after release should be a no-op + fig._fake_click((center + 5, 0.5), ax=hscroll, xform="data", kind="motion") + assert fig.mne.t_start == dragged_t_start + # test clicking a channel name in butterfly mode bads = fig.mne.info["bads"].copy() fig._fake_keypress("b")