"""Qt implementation of _Renderer and GUI.""" # Authors: The MNE-Python contributors. # License: BSD-3-Clause # Copyright the MNE-Python contributors. import os import platform import sys import weakref from contextlib import contextmanager # importing anything from qtpy forces a Qt API choice as a side effect, which is then # used by matplotlib and pyvistaqt from qtpy import API_NAME # noqa: F401, isort: skip import pyvista from matplotlib.backends.backend_qtagg import FigureCanvas from matplotlib.figure import Figure from pyvistaqt.plotting import FileDialog, MainWindow from qtpy.QtCore import ( QEvent, QLibraryInfo, QLocale, QObject, Qt, QTimer, # non-object-based-abstraction-only, deprecate Signal, ) from qtpy.QtGui import QCursor, QIcon, QKeyEvent from qtpy.QtWidgets import ( QButtonGroup, QCheckBox, QComboBox, # non-object-based-abstraction-only, deprecate QDockWidget, QDoubleSpinBox, QFileDialog, QGridLayout, QGroupBox, QHBoxLayout, QLabel, QLayout, QLineEdit, QMenuBar, QMessageBox, QProgressBar, QPushButton, QRadioButton, QScrollArea, QSizePolicy, QSlider, QSpinBox, QStyle, QStyleOptionSlider, QToolButton, QVBoxLayout, QWidget, ) from ...fixes import _compare_version from ...utils import _check_option, get_config from ..utils import safe_event from ._abstract import ( _AbstractAction, _AbstractAppWindow, _AbstractBrainMplCanvas, _AbstractButton, _AbstractCanvas, _AbstractCheckBox, _AbstractComboBox, _AbstractDialog, _AbstractDock, _AbstractFileButton, _AbstractGridLayout, _AbstractGroupBox, _AbstractHBoxLayout, _AbstractKeyPress, _AbstractLabel, _AbstractLayout, _AbstractMenuBar, _AbstractMplCanvas, _AbstractMplInterface, _AbstractPlayback, _AbstractPlayMenu, _AbstractPopup, _AbstractProgressBar, _AbstractRadioButtons, _AbstractSlider, _AbstractSpinBox, _AbstractStatusBar, _AbstractText, _AbstractToolBar, _AbstractVBoxLayout, _AbstractWdgt, _AbstractWidget, _AbstractWidgetList, _AbstractWindow, ) from ._pyvista import ( _check_3d_figure, # noqa: F401 _close_3d_figure, # noqa: F401 _close_all, # noqa: F401 _is_mesa, # noqa: F401 _PyVistaRenderer, _set_3d_title, # noqa: F401 _set_3d_view, # noqa: F401 _take_3d_screenshot, # noqa: F401 ) from ._utils import ( _ICONS_PATH, _init_mne_qtapp, _qt_app_exec, _qt_detect_theme, _qt_disable_paint, _qt_get_stylesheet, _qt_is_dark, _qt_raise_window, _qt_safe_window, ) from .renderer import _TimeInteraction # Adapted from matplotlib if ( sys.platform == "darwin" and _compare_version(platform.mac_ver()[0], ">=", "10.16") and QLibraryInfo.version().segments() <= [5, 15, 2] ): os.environ.setdefault("QT_MAC_WANTS_LAYER", "1") # fix for qscroll needing two layouts, one parent, one child def _get_layout(layout): if hasattr(layout, "_parent_layout"): return layout._parent_layout return layout # ------- # Widgets # ------- # The metaclasses need to share a base class in order for the inheritance # not to conflict, http://www.phyast.pitt.edu/~micheles/python/metatype.html # https://stackoverflow.com/questions/28720217/multiple-inheritance-metaclass-conflict class _BaseWidget(type(QWidget), type(_AbstractWidget)): pass # The inheritance has to be in this order for the _Widget and the opposite for # the widgets (e.g. _PushButton) that inherit from it, not sure why class _Widget(_AbstractWidget, QWidget, metaclass=_BaseWidget): tooltip = None _to_qt = dict( escape=Qt.Key_Escape, up=Qt.Key_Up, down=Qt.Key_Down, left=Qt.Key_Left, right=Qt.Key_Right, page_up=Qt.Key_PageUp, page_down=Qt.Key_PageDown, ) _from_qt = {v: k for k, v in _to_qt.items()} def __init__(self): _AbstractWidget.__init__() # QWidget.__init__(self) def _show(self): self.show() def _hide(self): self.hide() def _set_enabled(self, state): self.setEnabled(state) def _is_enabled(self): return self.isEnabled() def _update(self, repaint=True): self.update() if repaint: self.repaint() def _get_tooltip(self): return self.toolTip() def _set_tooltip(self, tooltip): self.setToolTip(tooltip) def _set_style(self, style): stylesheet = "" for key, val in style.items(): stylesheet = stylesheet + f"{key}:{val};" self.setStyleSheet(stylesheet) def _add_keypress(self, callback): self.keyPressEvent = lambda event: callback( self._from_qt[event.key()] if event.key() in self._from_qt else event.text() ) def _trigger_keypress(self, key): if key in self._to_qt: key_int = self._to_qt[key] else: key_int = getattr(Qt, f"Key_{key.upper()}") self.keyPressEvent( QKeyEvent(QEvent.KeyRelease, key_int, Qt.NoModifier, text=key) ) def _set_focus(self): self.setFocus() def _set_layout(self, layout): self.setLayout(_get_layout(layout)) def _set_theme(self, theme=None): if theme is None: default_theme = _qt_detect_theme() else: default_theme = theme theme = get_config("MNE_3D_OPTION_THEME", default_theme) stylesheet = _qt_get_stylesheet(theme) self.setStyleSheet(stylesheet) if _qt_is_dark(self): QIcon.setThemeName("dark") else: QIcon.setThemeName("light") def _set_size(self, width=None, height=None): if width: self.setMinimumWidth(width) self.setMaximumWidth(width) if height: self.setMinimumHeight(height) self.setMaximumHeight(height) class _Label(QLabel, _AbstractLabel, _Widget, metaclass=_BaseWidget): def __init__(self, value, center=False, selectable=False): _AbstractLabel.__init__(value, center=center, selectable=selectable) _Widget.__init__(self) QLabel.__init__(self) self.setText(value) if center: self.setAlignment(Qt.AlignCenter) self.setWordWrap(True) if selectable: self.setTextInteractionFlags(Qt.TextSelectableByMouse) class _Text(QLineEdit, _AbstractText, _Widget, metaclass=_BaseWidget): def __init__(self, value=None, placeholder=None, callback=None): _AbstractText.__init__(value=value, placeholder=placeholder, callback=callback) _Widget.__init__(self) QLineEdit.__init__(self, value) self.setPlaceholderText(placeholder) if callback is not None: self.textChanged.connect(callback) def _set_value(self, value): self.setText(value) class _Button(QPushButton, _AbstractButton, _Widget, metaclass=_BaseWidget): def __init__(self, value, callback, icon=None): _AbstractButton.__init__(value=value, callback=callback) _Widget.__init__(self) with _disabled_init(_AbstractButton): QPushButton.__init__(self) self.setText(value) self.released.connect(callback) if icon: self.setIcon(_qicon(icon)) def _click(self): self.click() def _set_icon(self, icon): self.setIcon(_qicon(icon)) class _Slider(QSlider, _AbstractSlider, _Widget, metaclass=_BaseWidget): def __init__(self, value, rng, callback, horizontal=True): _AbstractSlider.__init__( value=value, rng=rng, callback=callback, horizontal=horizontal ) _Widget.__init__(self) with _disabled_init(_AbstractSlider): QSlider.__init__(self, Qt.Horizontal if horizontal else Qt.Vertical) self.setMinimum(rng[0]) self.setMaximum(rng[1]) self.setValue(value) self.valueChanged.connect(callback) def _set_value(self, value): self.setValue(value) def _get_value(self): return self.value() def _set_range(self, rng): self.setRange(int(rng[0]), int(rng[1])) class _ProgressBar(QProgressBar, _AbstractProgressBar, _Widget, metaclass=_BaseWidget): def __init__(self, count): _AbstractProgressBar.__init__(count=count) _Widget.__init__(self) QProgressBar.__init__(self) self.setMaximum(count) def _increment(self): if self.value() + 1 > self.maximum(): return self.setValue(self.value() + 1) return self.value() class _CheckBox(QCheckBox, _AbstractCheckBox, _Widget, metaclass=_BaseWidget): def __init__(self, value, callback): _AbstractCheckBox.__init__(value=value, callback=callback) _Widget.__init__(self) with _disabled_init(_AbstractCheckBox): QCheckBox.__init__(self) self.setChecked(value) self.stateChanged.connect(lambda x: callback(bool(x))) def _set_checked(self, checked): self.setChecked(checked) def _get_checked(self): return self.checkState() != Qt.Unchecked class _SpinBox(QDoubleSpinBox, _AbstractSpinBox, _Widget, metaclass=_BaseWidget): def __init__(self, value, rng, callback, step=None): _AbstractSpinBox.__init__(value=value, rng=rng, callback=callback, step=step) _Widget.__init__(self) with _disabled_init(_AbstractSpinBox): QDoubleSpinBox.__init__(self) self.setAlignment(Qt.AlignCenter) self.setMinimum(rng[0]) self.setMaximum(rng[1]) self.setKeyboardTracking(False) if step is None: self.setSingleStep((rng[1] - rng[0]) / 20.0) else: self.setSingleStep(step) self.setValue(value) self.valueChanged.connect(callback) def _set_value(self, value): self.setValue(value) def _get_value(self): return self.value() class _ComboBox(QComboBox, _AbstractComboBox, _Widget, metaclass=_BaseWidget): def __init__(self, value, items, callback): _AbstractComboBox.__init__(value=value, items=items, callback=callback) _Widget.__init__(self) with _disabled_init(_AbstractComboBox): QComboBox.__init__(self) self.addItems(items) self.setCurrentText(value) self.currentTextChanged.connect(callback) self.setSizeAdjustPolicy(QComboBox.AdjustToContents) def _set_value(self, value): self.setCurrentText(value) def _get_value(self): return self.currentText() class _RadioButtons(QVBoxLayout, _AbstractRadioButtons, _Widget, metaclass=_BaseWidget): def __init__(self, value, items, callback): _AbstractRadioButtons.__init__(value=value, items=items, callback=callback) _Widget.__init__(self) with _disabled_init(_AbstractRadioButtons): QVBoxLayout.__init__(self) self._button_group = QButtonGroup() self._button_group.setExclusive(True) for val in items: button = QRadioButton(val) if val == value: button.setChecked(True) self._button_group.addButton(button) self.addWidget(button) self._button_group.buttonClicked.connect(lambda button: callback(button.text())) def _set_value(self, value): for button in self._button_group.buttons(): if button.text() == value: button.click() def _get_value(self): return self.checkedButton().text() class _GroupBox(QGroupBox, _AbstractGroupBox, _Widget, metaclass=_BaseWidget): def __init__(self, name, items): _AbstractGroupBox.__init__(name=name, items=items) _Widget.__init__(self) with _disabled_init(_AbstractGroupBox): QGroupBox.__init__(self, name) self._layout = _VBoxLayout() for item in items: self._layout._add_widget(item) self.setLayout(self._layout) class _FileButton(_Button, _AbstractFileButton, _Widget, metaclass=_BaseWidget): def __init__( self, callback, content_filter=None, initial_directory=None, save=False, is_directory=False, icon="folder", window=None, ): _AbstractFileButton.__init__( callback=callback, content_filter=content_filter, initial_directory=initial_directory, save=save, is_directory=is_directory, window=window, ) _Widget.__init__(self) def fp_callback(): if is_directory: name = QFileDialog.getExistingDirectory( parent=window, directory=initial_directory ) elif save: name = QFileDialog.getSaveFileName( parent=window, directory=initial_directory, filter=content_filter ) else: name = QFileDialog.getOpenFileName( parent=window, directory=initial_directory, filter=content_filter ) name = name[0] if isinstance(name, tuple) else name # handle the cancel button if len(name) == 0: return callback(name) _Button.__init__(self, "", callback=fp_callback, icon=icon) class _PlayMenu(QVBoxLayout, _AbstractPlayMenu, _Widget, metaclass=_BaseWidget): def __init__(self, value, rng, callback): _AbstractPlayMenu.__init__(value=value, rng=rng, callback=callback) _Widget.__init__(self) with _disabled_init(_AbstractPlayMenu): QVBoxLayout.__init__(self) self._slider = QSlider(Qt.Horizontal) self._slider.setMinimum(rng[0]) self._slider.setMaximum(rng[1]) self._slider.setValue(value) self._slider.setTracking(False) self._slider.valueChanged.connect(callback) self._nav_hbox = QHBoxLayout() self._play_button = QPushButton() self._play_button.setIcon(_qicon("play")) self._nav_hbox.addWidget(self._play_button) self._pause_button = QPushButton() self._pause_button.setIcon(_qicon("pause")) self._nav_hbox.addWidget(self._pause_button) self._reset_button = QPushButton() self._reset_button.setIcon(_qicon("reset")) self._nav_hbox.addWidget(self._reset_button) self._loop_button = QPushButton() self._loop_button.setIcon(_qicon("restore")) self._loop_button.setStyleSheet("background-color : lightgray;") self._loop_button._checked = True def loop_callback(): self._loop_button._checked = not self._loop_button._checked color = "lightgray" if self._loop_button._checked else "darkgray" self._loop_button.setStyleSheet(f"background-color : {color};") self._loop_button.released.connect(loop_callback) self._nav_hbox.addWidget(self._loop_button) self._timer = QTimer() def timer_callback(): value = self._slider.value() + 1 if value > rng[1]: if self._loop._checked: self._timer.stop() value = rng[0] self._slider.setValue(value) self._timer.timeout.connect(timer_callback) self._timer.setInterval(250) self._play_button.released.connect(self._timer.start) self._pause_button.released.connect(self._timer.stop) self._reset_button.released.connect(lambda: self._slider.setValue(rng[0])) self.addWidget(self._slider) self.addLayout(self._nav_hbox) def _play(self): self._play_button.click() def _pause(self): self._pause_button.click() def _reset(self): self._reset_button.click() def _loop(self): self._loop_button.click() def _set_value(self, value): self._slider.setValue(value) class _Popup(QMessageBox, _AbstractPopup, _Widget, metaclass=_BaseWidget): def __init__( self, title, text, info_text=None, callback=None, icon="warning", buttons=None, window=None, ): _AbstractPopup.__init__( self, title=title, text=text, info_text=info_text, callback=callback, icon=icon, buttons=buttons, window=window, ) _Widget.__init__(self) with _disabled_init(_AbstractPopup): QMessageBox.__init__(self, parent=window) self.setWindowTitle(title) self.setText(text) # icon is one of _Dialog.supported_icon_names if icon is not None: self.setIcon(getattr(QMessageBox, icon.title())) if info_text: self.setInformativeText(info_text) if buttons is None: buttons = ["Ok"] button_ids = list() for button in buttons: # button is one of _Dialog.supported_button_names button_id = getattr(QMessageBox, button) button_ids.append(button_id) standard_buttons = default_button = button_ids[0] for button_id in button_ids[1:]: standard_buttons |= button_id self.setStandardButtons(standard_buttons) self.setDefaultButton(default_button) if callback: self.buttonClicked.connect(lambda button: callback(button.text().title())) _qt_raise_window(self) self._show() def _click(self, value): self.button(getattr(QMessageBox, value)).click() class _ScrollArea(QScrollArea): def __init__(self, width, height, widget): QScrollArea.__init__(self) self.setWidget(widget) self.setFixedSize(width, height) self.setWidgetResizable(True) class _HBoxLayout(QHBoxLayout, _AbstractHBoxLayout, _Widget, metaclass=_BaseWidget): def __init__(self, height=None, scroll=None): _AbstractHBoxLayout.__init__(self, height=height, scroll=scroll) _Widget.__init__(self) QHBoxLayout.__init__(self) if scroll is not None: self._scroll_widget = QWidget() self._parent_layout = QHBoxLayout() self._parent_layout.addWidget( _ScrollArea(scroll[0], scroll[1], self._scroll_widget) ) self._scroll_widget.setLayout(self) self._height = height def _add_widget(self, widget): """Add a widget to an existing layout.""" if isinstance(widget, QLayout): self.addLayout(widget) else: if self._height is not None: widget.setMinimumHeight(self._height) widget.setMaximumHeight(self._height) self.addWidget(widget) def _add_stretch(self, amount=1): self.addStretch(amount) class _VBoxLayout(QVBoxLayout, _AbstractVBoxLayout, _Widget, metaclass=_BaseWidget): def __init__(self, width=None, scroll=None): _AbstractVBoxLayout.__init__(self, width=width, scroll=scroll) _Widget.__init__(self) QVBoxLayout.__init__(self) if scroll is not None: self._scroll_widget = QWidget() self._parent_layout = QHBoxLayout() self._parent_layout.addWidget( _ScrollArea(scroll[0], scroll[1], self._scroll_widget) ) self._scroll_widget.setLayout(self) self._width = width def _add_widget(self, widget): """Add a widget to an existing layout.""" if isinstance(widget, QLayout): self.addLayout(widget) else: if self._width is not None: widget.setMinimumWidth(self._width) widget.setMaximumWidth(self._width) self.addWidget(widget) def _add_stretch(self, amount=1): self.addStretch(amount) class _GridLayout(QGridLayout, _AbstractGridLayout, _Widget, metaclass=_BaseWidget): def __init__(self, height=None, width=None): _AbstractGridLayout.__init__(self) _Widget.__init__(self) QGridLayout.__init__(self) if height: self.setMinimumHeight(height) self.setMaximumHeight(height) if width: self.setMinimumWidth(width) self.setMaximumWidth(width) def _add_widget(self, widget, row=None, col=None): """Add a widget to an existing layout.""" if isinstance(widget, QLayout): self.addLayout(widget, row, col) else: self.addWidget(widget, row, col) class _BaseCanvas(type(FigureCanvas), type(_AbstractCanvas)): pass class _Canvas(FigureCanvas, _AbstractCanvas, metaclass=_BaseCanvas): def __init__(self, width, height, dpi): _AbstractCanvas.__init__(self, width=width, height=height, dpi=dpi) self.fig = Figure(figsize=(width, height), dpi=dpi) self.ax = self.fig.add_subplot(111, position=[0.15, 0.15, 0.75, 0.75]) FigureCanvas.__init__(self, self.fig) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.setMinimumWidth(width) self.setMinimumHeight(height) def _set_size(self, width=None, height=None): if width: self.setMinimumWidth(width) self.setMaximumWidth(width) if height: self.setMinimumHeight(height) self.setMaximumHeight(height) # %% # Windows # ------- # In theory we should be able to do this later (e.g., in _pyvista.py when # initializing), but at least on Qt6 this has to be done earlier. So let's do # it immediately upon instantiation of the QMainWindow class. # TODO: This should eventually allow us to handle # https://github.com/mne-tools/mne-python/issues/9182 # This is necessary to make PySide6 happy -- something weird with the # __init__ calling causes the _AbstractXYZ class __init__ to be called twice @contextmanager def _disabled_init(klass): orig = klass.__init__ klass.__init__ = lambda *args, **kwargs: None try: yield finally: klass.__init__ = orig class _MNEMainWindow(MainWindow): def __init__(self, parent=None, title=None, size=None): with _disabled_init(_Widget): MainWindow.__init__(self, parent=parent, title=title, size=size) self.setAttribute(Qt.WA_ShowWithoutActivating, True) self.setAttribute(Qt.WA_DeleteOnClose, True) from . import renderer if renderer.MNE_3D_BACKEND_TESTING: self.setWindowFlags(self.windowFlags() | Qt.WindowStaysOnBottomHint) class _AppWindow(_AbstractAppWindow, _MNEMainWindow, _Widget, metaclass=_BaseWidget): def __init__(self, size=None, fullscreen=False): self._app = _init_mne_qtapp() _AbstractAppWindow.__init__(self) _MNEMainWindow.__init__(self, size=size) _Widget.__init__(self) if fullscreen: self.setWindowState(Qt.WindowFullScreen) self._set_theme() self.setLocale(QLocale(QLocale.Language.English)) self.signal_close.connect(self._clean) self._before_close_callbacks = list() self._after_close_callbacks = list() # patch closeEvent def closeEvent(event): # functions to call before closing accept_close_event = True for callback in self._before_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.signal_close.emit() self._clean() event.accept() else: event.ignore() # functions to call after closing for callback in self._after_close_callbacks: callback() self.closeEvent = closeEvent def _set_central_layout(self, central_layout): central_widget = QWidget() central_widget.setLayout(_get_layout(central_layout)) self.setCentralWidget(central_widget) def _get_dpi(self): return self.windowHandle().screen().logicalDotsPerInch() def _get_size(self): return (self.width(), self.height()) def _get_cursor(self): return self.cursor() def _set_cursor(self, cursor): self.setCursor(cursor) def _new_cursor(self, name): return QCursor(getattr(Qt, name)) def _close_connect(self, callback, *, after=True): if after: self._after_close_callbacks.append(callback) else: self._before_close_callbacks.append(callback) def _close_disconnect(self, after=True): if after: self._after_close_callbacks.clear() else: self._before_close_callbacks.clear() def _clean(self): self._app = None def _show(self, block=False): _qt_raise_window(self) _Widget._show(self) if block: _qt_app_exec(self._app) def _close(self): self.close() class _3DRenderer(_PyVistaRenderer): _kind = "qt" @_qt_safe_window(always_close=False) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @_qt_safe_window() def show(self): super().show() with _qt_disable_paint(self.plotter): self.plotter.app_window.show() self._update() for plotter in self._all_plotters: plotter.updateGeometry() plotter._render() # Ideally we would just put a `splash.finish(plotter.window())` in the # same place that we initialize this (_init_qt_app call). However, # the window show event is triggered (closing the splash screen) well # before the window actually appears for complex scenes like the coreg # GUI. Therefore, we close after all these events have been processed # here. self._process_events() _qt_raise_window(self.plotter.app_window) def _clean(self): self.figure._plotter = None self._interactor = None # ------------------------------------ # Non-object-based Widget Abstractions # ------------------------------------ # These are planned to be deprecated in favor of the simpler, object- # oriented abstractions above when time allows. class _QtKeyPress(_AbstractKeyPress): _widget_id = 0 _callbacks = dict() _to_qt = dict( escape=Qt.Key_Escape, up=Qt.Key_Up, down=Qt.Key_Down, left=Qt.Key_Left, right=Qt.Key_Right, comma=Qt.Key_Comma, period=Qt.Key_Period, page_up=Qt.Key_PageUp, page_down=Qt.Key_PageDown, ) def _keypress_initialize(self, widget=None): widget = self._window if widget is None else widget self._widget_id = _QtKeyPress._widget_id _QtKeyPress._widget_id += 1 _QtKeyPress._callbacks[self._widget_id] = dict() def keyPressEvent(event): text = event.text() widget_callbacks = _QtKeyPress._callbacks[self._widget_id] if text in widget_callbacks: callback = widget_callbacks[text] callback() else: key = event.key() if key in widget_callbacks: callback = widget_callbacks[key] callback() widget.keyPressEvent = keyPressEvent def _keypress_add(self, shortcut, callback): widget_callbacks = _QtKeyPress._callbacks[self._widget_id] if len(shortcut) > 1: # special key shortcut = _QtKeyPress._to_qt[shortcut] widget_callbacks[shortcut] = callback def _keypress_trigger(self, shortcut): widget_callbacks = _QtKeyPress._callbacks[self._widget_id] if len(shortcut) > 1: # special key shortcut = _QtKeyPress._to_qt[shortcut] widget_callbacks[shortcut]() class _QtDialog(_AbstractDialog): # from QMessageBox.StandardButtons supported_button_names = [ "Ok", "Open", "Save", "Cancel", "Close", "Discard", "Apply", "Reset", "RestoreDefaults", "Help", "SaveAll", "Yes", "YesToAll", "No", "NoToAll", "Abort", "Retry", "Ignore", ] # from QMessageBox.Icon supported_icon_names = ["NoIcon", "Question", "Information", "Warning", "Critical"] def _dialog_create( self, title, text, info_text, callback, *, icon="Warning", buttons=(), modal=True, window=None, ): window = self._window if window is None else window widget = QMessageBox(window) widget.setWindowTitle(title) widget.setText(text) # icon is one of _QtDialog.supported_icon_names icon_id = getattr(QMessageBox, icon) widget.setIcon(icon_id) widget.setInformativeText(info_text) if not buttons: buttons = ["Ok"] button_ids = list() for button in buttons: # button is one of _QtDialog.supported_button_names button_id = getattr(QMessageBox, button) button_ids.append(button_id) standard_buttons = default_button = button_ids[0] for button_id in button_ids[1:]: standard_buttons |= button_id widget.setStandardButtons(standard_buttons) widget.setDefaultButton(default_button) @safe_event def func(button): button_id = widget.standardButton(button) for button_name in _QtDialog.supported_button_names: if button_id == getattr(QMessageBox, button_name): widget.setCursor(QCursor(Qt.WaitCursor)) try: callback(button_name) finally: widget.unsetCursor() break widget.buttonClicked.connect(func) return _QtDialogWidget(widget, modal) class _QtLayout(_AbstractLayout): def _layout_initialize(self, max_width): pass def _layout_add_widget(self, layout, widget, stretch=0, *, row=None, col=None): """Add a widget to an existing layout.""" if isinstance(widget, QLayout): layout.addLayout(widget) else: if isinstance(layout, QGridLayout): layout.addWidget(widget, row, col) else: layout.addWidget(widget, stretch) def _layout_create(self, orientation="vertical"): if orientation == "vertical": layout = QVBoxLayout() elif orientation == "horizontal": layout = QHBoxLayout() else: assert orientation == "grid" layout = QGridLayout() return layout class _QtDock(_AbstractDock, _QtLayout): def _dock_initialize( self, window=None, name="Controls", area="left", max_width=None ): window = self._window if window is None else window qt_area = getattr(Qt, f"{area.capitalize()}DockWidgetArea") self._dock, self._dock_layout = _create_dock_widget( window, name, qt_area, max_width=max_width ) if area == "left": window.setCorner(Qt.BottomLeftCorner, Qt.LeftDockWidgetArea) else: window.setCorner(Qt.BottomRightCorner, Qt.RightDockWidgetArea) def _dock_finalize(self): self._dock.setMinimumSize(self._dock.sizeHint().width(), 0) self._dock_add_stretch(self._dock_layout) def _dock_show(self): self._dock.show() def _dock_hide(self): self._dock.hide() def _dock_add_stretch(self, layout=None): layout = self._dock_layout if layout is None else layout layout.addStretch() def _dock_add_layout(self, vertical=True): layout = QVBoxLayout() if vertical else QHBoxLayout() return layout def _dock_add_label(self, value, *, align=False, layout=None, selectable=False): layout = self._dock_layout if layout is None else layout widget = QLabel() if align: widget.setAlignment(Qt.AlignCenter) widget.setText(value) widget.setWordWrap(True) if selectable: widget.setTextInteractionFlags(Qt.TextSelectableByMouse) self._layout_add_widget(layout, widget) return _QtWidget(widget) def _dock_add_button( self, name, callback, *, style="pushbutton", icon=None, tooltip=None, layout=None, ): _check_option( parameter="style", value=style, allowed_values=("toolbutton", "pushbutton") ) if style == "toolbutton": widget = QToolButton() widget.setText(name) else: widget = QPushButton(name) # Don't change text color upon button press widget.setStyleSheet("QPushButton:pressed {color: none;}") if icon is not None: widget.setIcon(self._icons[icon]) _set_widget_tooltip(widget, tooltip) widget.clicked.connect(callback) layout = self._dock_layout if layout is None else layout self._layout_add_widget(layout, widget) return _QtWidget(widget) def _dock_named_layout(self, name, *, layout=None, compact=True): layout = self._dock_layout if layout is None else layout if name is not None: hlayout = self._dock_add_layout(not compact) self._dock_add_label(value=name, align=not compact, layout=hlayout) self._layout_add_widget(layout, hlayout) layout = hlayout return layout def _dock_add_slider( self, name, value, rng, callback, *, compact=True, double=False, tooltip=None, layout=None, ): layout = self._dock_named_layout(name=name, layout=layout, compact=compact) slider_class = QFloatSlider if double else QSlider cast = float if double else int widget = slider_class(Qt.Horizontal) _set_widget_tooltip(widget, tooltip) widget.setMinimum(cast(rng[0])) widget.setMaximum(cast(rng[1])) widget.setValue(cast(value)) if double: widget.floatValueChanged.connect(callback) else: widget.valueChanged.connect(callback) self._layout_add_widget(layout, widget) return _QtWidget(widget) def _dock_add_check_box(self, name, value, callback, *, tooltip=None, layout=None): layout = self._dock_layout if layout is None else layout widget = QCheckBox(name) _set_widget_tooltip(widget, tooltip) widget.setChecked(value) widget.stateChanged.connect(callback) self._layout_add_widget(layout, widget) return _QtWidget(widget) def _dock_add_spin_box( self, name, value, rng, callback, *, compact=True, double=True, step=None, tooltip=None, layout=None, ): layout = self._dock_named_layout(name=name, layout=layout, compact=compact) value = value if double else int(value) widget = QDoubleSpinBox() if double else QSpinBox() _set_widget_tooltip(widget, tooltip) widget.setAlignment(Qt.AlignCenter) widget.setMinimum(rng[0]) widget.setMaximum(rng[1]) widget.setKeyboardTracking(False) if step is None: inc = (rng[1] - rng[0]) / 20.0 inc = max(int(round(inc)), 1) if not double else inc widget.setSingleStep(inc) else: widget.setSingleStep(step) widget.setValue(value) widget.valueChanged.connect(callback) self._layout_add_widget(layout, widget) return _QtWidget(widget) def _dock_add_combo_box( self, name, value, rng, callback, *, compact=True, tooltip=None, layout=None ): layout = self._dock_named_layout(name=name, layout=layout, compact=compact) widget = QComboBox() _set_widget_tooltip(widget, tooltip) widget.addItems(rng) widget.setCurrentText(value) widget.currentTextChanged.connect(callback) widget.setSizeAdjustPolicy(QComboBox.AdjustToContents) self._layout_add_widget(layout, widget) return _QtWidget(widget) def _dock_add_radio_buttons( self, value, rng, callback, *, vertical=True, layout=None ): layout = self._dock_layout if layout is None else layout group_layout = QVBoxLayout() if vertical else QHBoxLayout() group = QButtonGroup() for val in rng: button = QRadioButton(val) if val == value: button.setChecked(True) group.addButton(button) self._layout_add_widget(group_layout, button) def func(button): callback(button.text()) group.buttonClicked.connect(func) self._layout_add_widget(layout, group_layout) return _QtWidgetList(group) def _dock_add_group_box(self, name, *, collapse=None, layout=None): layout = self._dock_layout if layout is None else layout hlayout = QVBoxLayout() widget = QGroupBox(name) widget.setLayout(hlayout) self._layout_add_widget(layout, widget) return hlayout def _dock_add_text(self, name, value, placeholder, *, callback=None, layout=None): layout = self._dock_layout if layout is None else layout widget = QLineEdit(value) widget.setPlaceholderText(placeholder) self._layout_add_widget(layout, widget) if callback is not None: widget.textChanged.connect(callback) return _QtWidget(widget) def _dock_add_file_button( self, name, desc, func, *, filter_=None, initial_directory=None, save=False, is_directory=False, icon=False, tooltip=None, layout=None, ): layout = self._dock_layout if layout is None else layout weakself = weakref.ref(self) def callback(): self = weakself() if not self: return if is_directory: name = QFileDialog.getExistingDirectory( parent=self._window, directory=initial_directory ) elif save: name = QFileDialog.getSaveFileName( parent=self._window, directory=initial_directory, filter=filter_ ) else: name = QFileDialog.getOpenFileName( parent=self._window, directory=initial_directory, filter=filter_ ) name = name[0] if isinstance(name, tuple) else name # handle the cancel button if len(name) == 0: return func(name) if icon: kwargs = dict(style="toolbutton", icon="folder") else: kwargs = dict() button_widget = self._dock_add_button( name=desc, callback=callback, tooltip=tooltip, layout=layout, **kwargs ) return button_widget # It's already a _QtWidget instance class QFloatSlider(QSlider): """Slider that handles float values.""" floatValueChanged = Signal(float) def __init__(self, ori, parent=None): """Initialize the slider.""" super().__init__(ori, parent) self._opt = QStyleOptionSlider() self.initStyleOption(self._opt) self._gr = self.style().subControlRect( QStyle.CC_Slider, self._opt, QStyle.SC_SliderGroove, self ) self._sr = self.style().subControlRect( QStyle.CC_Slider, self._opt, QStyle.SC_SliderHandle, self ) self._precision = 10000 super().valueChanged.connect(self._convert) def _convert(self, value): self.floatValueChanged.emit(value / self._precision) def minimum(self): """Get the minimum.""" return super().minimum() / self._precision def setMinimum(self, value): """Set the minimum.""" super().setMinimum(int(value * self._precision)) def maximum(self): """Get the maximum.""" return super().maximum() / self._precision def setMaximum(self, value): """Set the maximum.""" super().setMaximum(int(value * self._precision)) def value(self): """Get the current value.""" return super().value() / self._precision def setValue(self, value): """Set the current value.""" super().setValue(int(value * self._precision)) # Adapted from: # https://stackoverflow.com/questions/52689047/moving-qslider-to-mouse-click-position # noqa: E501 def mousePressEvent(self, event): """Add snap-to-location handling.""" opt = QStyleOptionSlider() self.initStyleOption(opt) sr = self.style().subControlRect( QStyle.CC_Slider, opt, QStyle.SC_SliderHandle, self ) if event.button() != Qt.LeftButton or sr.contains(event.pos()): super().mousePressEvent(event) return if self.orientation() == Qt.Vertical: half = (0.5 * sr.height()) + 0.5 max_ = self.height() pos = max_ - event.pos().y() else: half = (0.5 * sr.width()) + 0.5 max_ = self.width() pos = event.pos().x() max_ = max_ - 2 * half pos = min(max(pos - half, 0), max_) / max_ val = self.minimum() + (self.maximum() - self.minimum()) * pos val = (self.maximum() - val) if self.invertedAppearance() else val self.setValue(val) event.accept() # Process afterward so it's seen as a drag super().mousePressEvent(event) class _QtToolBar(_AbstractToolBar, _QtLayout): def _tool_bar_initialize(self, name="default", window=None): self.actions = dict() window = self._window if window is None else window self._tool_bar = window.addToolBar(name) self._tool_bar_layout = self._tool_bar.layout() def _tool_bar_add_button(self, name, desc, func, *, icon_name=None, shortcut=None): icon_name = name if icon_name is None else icon_name icon = self._icons[icon_name] self.actions[name] = _QtAction(self._tool_bar.addAction(icon, desc, func)) if shortcut is not None: self.actions[name].set_shortcut(shortcut) def _tool_bar_update_button_icon(self, name, icon_name): self.actions[name].set_icon(self._icons[icon_name]) def _tool_bar_add_text(self, name, value, placeholder): pass def _tool_bar_add_spacer(self): spacer = QWidget() spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) self._tool_bar.addWidget(spacer) def _tool_bar_add_file_button(self, name, desc, func, *, shortcut=None): weakself = weakref.ref(self) def callback(weakself=weakself): weakself = weakself() if weakself is None: return return FileDialog( weakself._window, callback=func, ) self._tool_bar_add_button( name=name, desc=desc, func=callback, shortcut=shortcut, ) def _tool_bar_add_play_button(self, name, desc, func, *, shortcut=None): self._tool_bar_add_button( name=name, desc=desc, func=func, icon_name=None, shortcut=shortcut ) class _QtMenuBar(_AbstractMenuBar): def _menu_initialize(self, window=None): window = self._window if window is None else window self._menus = dict() self._menu_actions = dict() self._menu_bar = QMenuBar(window) self._menu_bar.setNativeMenuBar(False) def _menu_add_submenu(self, name, desc): self._menus[name] = self._menu_bar.addMenu(desc) self._menu_actions[name] = dict() def _menu_add_button(self, menu_name, name, desc, func): menu = self._menus[menu_name] self._menu_actions[menu_name][name] = _QtAction(menu.addAction(desc, func)) class _QtStatusBar(_AbstractStatusBar, _QtLayout): def _status_bar_initialize(self, window=None): window = self._window if window is None else window self._status_bar = window.statusBar() def _status_bar_add_label(self, value, *, stretch=0): widget = QLabel(value) self._layout_add_widget(self._status_bar.layout(), widget, stretch) return _QtWidget(widget) def _status_bar_add_progress_bar(self, stretch=0): widget = QProgressBar() self._layout_add_widget(self._status_bar.layout(), widget, stretch) return _QtWidget(widget) def _status_bar_update(self): self._status_bar.layout().update() class _QtPlayback(_AbstractPlayback): def _playback_initialize(self, func, timeout, value, rng, time_widget, play_widget): self.figure.plotter.add_callback(func, timeout) class _QtMplInterface(_AbstractMplInterface): def _mpl_initialize(self): from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg from qtpy import QtWidgets self.canvas = FigureCanvasQTAgg(self.fig) FigureCanvasQTAgg.setSizePolicy( self.canvas, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding, ) FigureCanvasQTAgg.updateGeometry(self.canvas) class _QtMplCanvas(_AbstractMplCanvas, _QtMplInterface): def __init__(self, width, height, dpi): super().__init__(width, height, dpi) self._mpl_initialize() class _QtBrainMplCanvas(_AbstractBrainMplCanvas, _QtMplInterface): def __init__(self, brain, width, height, dpi): super().__init__(brain, width, height, dpi) self._mpl_initialize() if brain.separate_canvas: self.canvas.setParent(None) else: self.canvas.setParent(brain._renderer._window) self._connect() class _QtWindow(_AbstractWindow): def _window_initialize(self, *, window=None, central_layout=None, fullscreen=False): super()._window_initialize() self._interactor = self.figure.plotter.interactor if window is None: self._window = self.figure.plotter.app_window else: self._window = window if fullscreen: self._window.setWindowState(Qt.WindowFullScreen) if central_layout is not None: central_widget = self._window.centralWidget() if central_widget is None: central_widget = QWidget() self._window.setCentralWidget(central_widget) central_widget.setLayout(central_layout) self._window_load_icons() self._window_set_theme() self._window.setLocale(QLocale(QLocale.Language.English)) self._window.signal_close.connect(self._window_clean) self._window_before_close_callbacks = list() self._window_after_close_callbacks = list() # patch closeEvent def closeEvent(event): # functions to call before closing accept_close_event = True for callback in self._window_before_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() # functions to call after closing for callback in self._window_after_close_callbacks: callback() self._window.closeEvent = closeEvent def _window_load_icons(self): self._icons["help"] = _qicon("help") self._icons["play"] = _qicon("play") self._icons["pause"] = _qicon("pause") self._icons["reset"] = _qicon("reset") self._icons["scale"] = _qicon("scale") self._icons["clear"] = _qicon("clear") self._icons["movie"] = _qicon("movie") self._icons["restore"] = _qicon("restore") self._icons["screenshot"] = _qicon("screenshot") self._icons["visibility_on"] = _qicon("visibility_on") self._icons["visibility_off"] = _qicon("visibility_off") self._icons["folder"] = _qicon("folder") def _window_clean(self): self.figure._plotter = None self._interactor = None def _window_close_connect(self, func, *, after=True): if after: self._window_after_close_callbacks.append(func) else: self._window_before_close_callbacks.append(func) def _window_close_disconnect(self, after=True): if after: self._window_after_close_callbacks.clear() else: self._window_before_close_callbacks.clear() def _window_get_dpi(self): return self._window.windowHandle().screen().logicalDotsPerInch() def _window_get_size(self): w = self._interactor.geometry().width() h = self._interactor.geometry().height() return (w, h) def _window_get_simple_canvas(self, width, height, dpi): return _QtMplCanvas(width, height, dpi) def _window_get_mplcanvas( self, brain, interactor_fraction, show_traces, separate_canvas ): w, h = self._window_get_mplcanvas_size(interactor_fraction) self._interactor_fraction = interactor_fraction self._show_traces = show_traces self._separate_canvas = separate_canvas self._mplcanvas = _QtBrainMplCanvas(brain, w, h, self._window_get_dpi()) return self._mplcanvas def _window_adjust_mplcanvas_layout(self): canvas = self._mplcanvas.canvas self._mpl_dock, dock_layout = _create_dock_widget( self._window, "Traces", Qt.BottomDockWidgetArea ) dock_layout.addWidget(canvas) def _window_get_cursor(self): return self._window.cursor() def _window_set_cursor(self, cursor): self._interactor.setCursor(cursor) self._window.setCursor(cursor) def _window_new_cursor(self, name): return QCursor(getattr(Qt, name)) @contextmanager def _window_ensure_minimum_sizes(self): sz = self.figure.store["window_size"] adjust_mpl = self._show_traces and not self._separate_canvas # plotter: pyvista.plotting.qt_plotting.BackgroundPlotter # plotter.interactor: vtk.qt.QVTKRenderWindowInteractor.QVTKRenderWindowInteractor -> QWidget # noqa # plotter.app_window: pyvista.plotting.qt_plotting.MainWindow -> QMainWindow # noqa # plotter.frame: QFrame with QVBoxLayout with plotter.interactor as centralWidget # noqa # plotter.ren_win: vtkXOpenGLRenderWindow self._interactor.setMinimumSize(*sz) # Lines like this are useful for debugging these issues: # print('*' * 80) # print(0, self._interactor.app_window.size().height(), self._interactor.size().height(), self._mpl_dock.widget().height(), self._mplcanvas.canvas.size().height()) # noqa if adjust_mpl: mpl_h = int( round( (sz[1] * self._interactor_fraction) / (1 - self._interactor_fraction) ) ) self._mplcanvas.canvas.setMinimumSize(sz[0], mpl_h) self._mpl_dock.widget().setMinimumSize(sz[0], mpl_h) try: yield # show finally: # 1. Process events self._process_events() self._process_events() # 2. Get the window and interactor sizes that work win_sz = self._window.size() ren_sz = self._interactor.size() # 3. Undo the min size setting and process events self._interactor.setMinimumSize(0, 0) if adjust_mpl: self._mplcanvas.canvas.setMinimumSize(0, 0) self._mpl_dock.widget().setMinimumSize(0, 0) self._process_events() self._process_events() # 4. Compute the extra height required for dock decorations and add win_h = win_sz.height() if adjust_mpl: win_h += max(self._mpl_dock.widget().size().height() - mpl_h, 0) # 5. Resize the window and interactor to the correct size # (not sure why, but this is required on macOS at least) self._interactor.window_size = (win_sz.width(), win_h) self._interactor.resize(ren_sz.width(), ren_sz.height()) self._process_events() self._process_events() def _window_set_theme(self, theme=None): if theme is None: default_theme = _qt_detect_theme() else: default_theme = theme theme = get_config("MNE_3D_OPTION_THEME", default_theme) stylesheet = _qt_get_stylesheet(theme) self._window.setStyleSheet(stylesheet) if _qt_is_dark(self._window): QIcon.setThemeName("dark") else: QIcon.setThemeName("light") def _window_create(self): return _MNEMainWindow() class _QtWidgetList(_AbstractWidgetList): def __init__(self, src): self._src = src self._widgets = list() if isinstance(self._src, QButtonGroup): widgets = self._src.buttons() else: widgets = src for widget in widgets: if not isinstance(widget, _QtWidget): widget = _QtWidget(widget) self._widgets.append(widget) def set_enabled(self, state): for widget in self._widgets: widget.set_enabled(state) def get_value(self, idx): return self._widgets[idx].get_value() def set_value(self, idx, value): if isinstance(self._src, QButtonGroup): self._widgets[idx].set_value(True) else: self._widgets[idx].set_value(value) class _QtWidget(_AbstractWdgt): def set_value(self, value): if isinstance(self._widget, (QRadioButton, QToolButton, QPushButton)): self._widget.click() else: if hasattr(self._widget, "setValue"): self._widget.setValue(value) elif hasattr(self._widget, "setCurrentText"): self._widget.setCurrentText(value) elif hasattr(self._widget, "setChecked"): self._widget.setChecked(value) else: assert hasattr(self._widget, "setText") self._widget.setText(value) def get_value(self): if hasattr(self._widget, "value"): return self._widget.value() elif hasattr(self._widget, "currentText"): return self._widget.currentText() elif hasattr(self._widget, "checkState"): return self._widget.checkState() != Qt.Unchecked else: assert hasattr(self._widget, "text") return self._widget.text() def set_range(self, rng): self._widget.setRange(rng[0], rng[1]) def show(self): self._widget.show() def hide(self): self._widget.hide() def set_enabled(self, state): self._widget.setEnabled(state) def is_enabled(self): return self._widget.isEnabled() def update(self, repaint=True): self._widget.update() if repaint: self._widget.repaint() def get_tooltip(self): assert hasattr(self._widget, "toolTip") return self._widget.toolTip() def set_tooltip(self, tooltip): assert hasattr(self._widget, "setToolTip") self._widget.setToolTip(tooltip) def set_style(self, style): stylesheet = "" for key, val in style.items(): stylesheet = stylesheet + f"{key}:{val};" self._widget.setStyleSheet(stylesheet) class _QtDialogCommunicator(QObject): signal_show = Signal() def __init__(self, parent=None): super().__init__(parent) class _QtDialogWidget(_QtWidget): def __init__(self, widget, modal): super().__init__(widget) self._modal = modal self._communicator = _QtDialogCommunicator() self._communicator.signal_show.connect(self.show) self._widget.setAttribute(Qt.WA_DeleteOnClose, True) def trigger(self, button): button_id = getattr(QMessageBox, button) for current_button in self._widget.buttons(): if self._widget.standardButton(current_button) == button_id: current_button.click() break def show(self, thread=False): if thread: self._communicator.signal_show.emit() else: if self._modal: self._widget.exec() else: self._widget.show() class _QtAction(_AbstractAction): def trigger(self): self._action.trigger() def set_icon(self, icon): self._action.setIcon(icon) def set_shortcut(self, shortcut): self._action.setShortcut(shortcut) class _Renderer( _PyVistaRenderer, _QtDock, _QtToolBar, _QtMenuBar, _QtStatusBar, _QtWindow, _QtPlayback, _QtDialog, _QtKeyPress, _TimeInteraction, ): _kind = "qt" @_qt_safe_window(always_close=False) def __init__(self, *args, **kwargs): fullscreen = kwargs.pop("fullscreen", False) super().__init__(*args, **kwargs) self._window_initialize(fullscreen=fullscreen) @_qt_safe_window() def show(self): super().show() with _qt_disable_paint(self.plotter): with self._window_ensure_minimum_sizes(): self.plotter.app_window.show() self._update() for plotter in self._all_plotters: plotter.updateGeometry() plotter._render() # Ideally we would just put a `splash.finish(plotter.window())` in the # same place that we initialize this (_init_qt_app call). However, # the window show event is triggered (closing the splash screen) well # before the window actually appears for complex scenes like the coreg # GUI. Therefore, we close after all these events have been processed # here. self._process_events() _qt_raise_window(self.plotter.app_window) def _set_widget_tooltip(widget, tooltip): if tooltip is not None: widget.setToolTip(tooltip) def _create_dock_widget(window, name, area, *, max_width=None): # create dock widget dock = QDockWidget(name) # add scroll area scroll = QScrollArea(dock) dock.setWidget(scroll) # give the scroll area a child widget widget = QWidget(scroll) scroll.setWidget(widget) scroll.setWidgetResizable(True) dock.setAllowedAreas(area) dock.setTitleBarWidget(QLabel(name)) window.addDockWidget(area, dock) dock_layout = QVBoxLayout() widget.setLayout(dock_layout) # Fix resize grip size # https://stackoverflow.com/a/65050468/2175965 styles = ["margin: 4px;"] if max_width is not None: styles.append(f"max-width: {max_width};") style_sheet = "QDockWidget { " + " \n".join(styles) + "\n}" dock.setStyleSheet(style_sheet) return dock, dock_layout @contextmanager def _testing_context(interactive): from . import renderer orig_offscreen = pyvista.OFF_SCREEN orig_testing = renderer.MNE_3D_BACKEND_TESTING renderer.MNE_3D_BACKEND_TESTING = True if interactive: pyvista.OFF_SCREEN = False else: pyvista.OFF_SCREEN = True try: yield finally: pyvista.OFF_SCREEN = orig_offscreen renderer.MNE_3D_BACKEND_TESTING = orig_testing def _qicon(name): # Get icon from theme with a file fallback return QIcon.fromTheme( name, QIcon(str(_ICONS_PATH / "light" / "actions" / f"{name}.svg")) )