diff --git a/mne/gui/_coreg.py b/mne/gui/_coreg.py index a90333ccdb9..5738f24d3bc 100644 --- a/mne/gui/_coreg.py +++ b/mne/gui/_coreg.py @@ -174,6 +174,11 @@ def _get_default(var, val): self._to_cf_t = None self._omit_hsp_distance = 0.0 self._fiducials_file = None + self._trans_modified = False + self._mri_fids_modified = False + self._mri_scale_modified = False + self._accept_close_event = True + self._auto_cleanup = True self._fid_colors = tuple( DEFAULTS['coreg'][f'{key}_color'] for key in ('lpa', 'nasion', 'rpa')) @@ -225,7 +230,7 @@ def _get_default(var, val): # setup the window self._renderer = _get_renderer( size=self._defaults["size"], bgcolor=self._defaults["bgcolor"]) - self._renderer._window_close_connect(self._clean) + self._renderer._window_close_connect(self._close_callback) self._renderer.set_interaction(interaction) self._renderer._status_bar_initialize() @@ -309,6 +314,10 @@ def _get_default(var, val): self._renderer.plotter.add_callback( self._redraw, self._refresh_rate_ms) self._renderer.plotter.show_axes() + # initialization does not count as modification by the user + self._trans_modified = False + self._mri_fids_modified = False + self._mri_scale_modified = False if block and self._renderer._kind != 'notebook': _qt_app_exec(self._renderer.figure.store["app"]) @@ -432,6 +441,7 @@ def _set_scale_mode(self, mode): self._scale_mode = mode def _set_fiducial(self, value, coord): + self._mri_fids_modified = True fid = self._current_fiducial fid_idx = _map_fid_name_to_idx(name=fid) @@ -442,6 +452,10 @@ def _set_fiducial(self, value, coord): self._update_plot("mri_fids") def _set_parameter(self, value, mode_name, coord): + if mode_name == "scale": + self._mri_scale_modified = True + else: + self._trans_modified = True if self._params_locked: return if mode_name == "scale" and self._scale_mode == "uniform": @@ -1222,6 +1236,7 @@ def _save_subject(self): self._display_message(f"Computing {bem_name} solution..." " Done!") self._display_message(f"Saving {self._subject_to}... Done!") + self._mri_scale_modified = False def _save_mri_fiducials(self, fname): self._display_message(f"Saving {fname}...") @@ -1231,11 +1246,13 @@ def _save_mri_fiducials(self, fname): ) self._set_fiducials_file(fname) self._display_message(f"Saving {fname}... Done!") + self._mri_fids_modified = False def _save_trans(self, fname): write_trans(fname, self.coreg.trans, overwrite=True) self._display_message( f"{fname} transform file is saved.") + self._trans_modified = False def _load_trans(self, fname): mri_head_t = _ensure_trans(read_trans(fname, return_all=True), @@ -1697,6 +1714,10 @@ def _configure_status_bar(self): 'status_message', 'hide', value=None, input_value=False ) + def _set_automatic_cleanup(self, state): + """Enable/Disable automatic cleanup (for testing purposes only).""" + self._auto_cleanup = state + def _clean(self): self._renderer = None self._widgets.clear() @@ -1709,3 +1730,66 @@ def _clean(self): def close(self): """Close interface and cleanup data structure.""" self._renderer.close() + + def _close_dialog_callback(self, button_name): + from ..viz.backends.renderer import MNE_3D_BACKEND_TESTING + self._accept_close_event = True + if button_name == "Save": + if self._trans_modified: + self._forward_widget_command( + "save_trans", "set_value", None) + # cancel means _save_trans is not called + if self._trans_modified: + self._accept_close_event = False + if self._mri_fids_modified: + self._forward_widget_command( + "save_mri_fids", "set_value", None) + if self._mri_scale_modified: + if self._subject_to: + self._save_subject() + else: + dialog = self._renderer._dialog_warning( + title="CoregistrationUI", + text="The name of the output subject used to " + "save the scaled anatomy is not set.", + info_text="Please set a subject name", + callback=lambda x: None, + buttons=["Ok"], + modal=not MNE_3D_BACKEND_TESTING, + ) + dialog.show() + self._accept_close_event = False + elif button_name == "Cancel": + self._accept_close_event = False + else: + assert button_name == "Discard" + + def _close_callback(self): + if self._trans_modified or self._mri_fids_modified or \ + self._mri_scale_modified: + from ..viz.backends.renderer import MNE_3D_BACKEND_TESTING + # prepare the dialog's text + text = "The following is/are not saved:" + text += "" + self._widgets["close_dialog"] = self._renderer._dialog_warning( + title="CoregistrationUI", + text=text, + info_text="Do you want to save?", + callback=self._close_dialog_callback, + buttons=["Save", "Discard", "Cancel"], + # modal=True means that the dialog blocks the application + # when show() is called, until one of the buttons is clicked + modal=not MNE_3D_BACKEND_TESTING, + ) + self._widgets["close_dialog"].show() + + if self._accept_close_event and self._auto_cleanup: + self._clean() + return self._accept_close_event diff --git a/mne/gui/tests/test_coreg_gui.py b/mne/gui/tests/test_coreg_gui.py index 2f3a5142753..f25e4980e94 100644 --- a/mne/gui/tests/test_coreg_gui.py +++ b/mne/gui/tests/test_coreg_gui.py @@ -146,6 +146,7 @@ def test_coreg_gui_pyvista(tmp_path, renderer_interactive_pyvistaqt): assert coreg._fiducials_file == fid_fname # fitting (with scaling) + assert not coreg._mri_scale_modified coreg._reset() coreg._reset_fitting_parameters() coreg._set_scale_mode("uniform") @@ -161,6 +162,7 @@ def test_coreg_gui_pyvista(tmp_path, renderer_interactive_pyvistaqt): atol=1e-3) coreg._set_scale_mode("None") coreg._set_icp_fid_match("matched") + assert coreg._mri_scale_modified # unlock fiducials assert coreg._lock_fids @@ -168,12 +170,14 @@ def test_coreg_gui_pyvista(tmp_path, renderer_interactive_pyvistaqt): assert not coreg._lock_fids # picking + assert not coreg._mri_fids_modified vtk_picker = TstVTKPicker(coreg._surfaces['head'], 0, (0, 0)) coreg._on_mouse_move(vtk_picker, None) coreg._on_button_press(vtk_picker, None) coreg._on_pick(vtk_picker, None) coreg._on_button_release(vtk_picker, None) coreg._on_pick(vtk_picker, None) # also pick when locked + assert coreg._mri_fids_modified # lock fiducials coreg._set_lock_fids(True) @@ -213,11 +217,18 @@ def test_coreg_gui_pyvista(tmp_path, renderer_interactive_pyvistaqt): assert coreg._head_resolution == \ (config.get('MNE_COREG_HEAD_HIGH_RES', 'true') == 'true') + assert coreg._trans_modified tmp_trans = tmp_path / 'tmp-trans.fif' coreg._save_trans(tmp_trans) + assert not coreg._trans_modified assert op.isfile(tmp_trans) + # test _close_callback() + coreg._set_automatic_cleanup(False) coreg.close() + coreg._widgets['close_dialog'].trigger('Discard') # do not save + coreg._clean() # finally, cleanup internal structures + # Coregistration instance should survive assert isinstance(coreg.coreg, Coregistration) diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index 6b0c4929786..6da59bc1105 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -736,7 +736,7 @@ def _clean(self): for renderer in self._renderer._all_renderers: renderer.RemoveAllLights() # app_window cannot be set to None because it is used in __del__ - for key in ('lighting', 'interactor', '_RenderWindow'): + for key in ('lighting', 'interactor'): setattr(self.plotter, key, None) # Qt LeaveEvent requires _Iren so we use _FakeIren instead of None # to resolve the ref to vtkGenericRenderWindowInteractor diff --git a/mne/viz/backends/_qt.py b/mne/viz/backends/_qt.py index bbdbc4cca2b..28ebdbdd953 100644 --- a/mne/viz/backends/_qt.py +++ b/mne/viz/backends/_qt.py @@ -59,7 +59,12 @@ def _dialog_warning(self, title, text, info_text, callback, *, widget.setDefaultButton(default_button) def func(button): - callback(button.text()) + # the text of the button may be prefixed by '&' + button_name = button.text().replace('&', '') + # handle MacOS Discard button + button_name = "Discard" \ + if button_name == "Don't Save" else button_name + callback(button_name) widget.buttonClicked.connect(func) return _QtDialogWidget(widget, modal) @@ -539,13 +544,29 @@ def _window_initialize(self): self._window = self.figure.plotter.app_window self._window.setLocale(QLocale(QLocale.Language.English)) self._window.signal_close.connect(self._window_clean) + self._window_close_callbacks = list() + + # patch closeEvent + def closeEvent(event): + accept_close_event = True + for callback in self._window_close_callbacks: + ret = callback() + # check if one of the callbacks ignores the close event + if isinstance(ret, bool) and not ret: + accept_close_event = False + if accept_close_event: + self._window.signal_close.emit() + event.accept() + else: + event.ignore() + self._window.closeEvent = closeEvent def _window_clean(self): self.figure._plotter = None self._interactor = None def _window_close_connect(self, func): - self._window.signal_close.connect(func) + self._window_close_callbacks.append(func) def _window_get_dpi(self): return self._window.windowHandle().screen().logicalDotsPerInch()