# # Authors: The MNE-Python contributors. # License: BSD-3-Clause # Copyright the MNE-Python contributors. import collections.abc import functools import os import platform import signal import sys from colorsys import rgb_to_hls from contextlib import contextmanager from ctypes import c_char_p, c_void_p, cdll from pathlib import Path import numpy as np from ...fixes import _compare_version from ...utils import _check_qt_version, _validate_type, logger, warn from ..utils import _get_cmap VALID_BROWSE_BACKENDS = ( "qt", "matplotlib", ) VALID_3D_BACKENDS = ( "pyvistaqt", # default 3d backend "notebook", ) ALLOWED_QUIVER_MODES = ("2darrow", "arrow", "cone", "cylinder", "sphere", "oct") _ICONS_PATH = Path(__file__).parents[2] / "icons" def _get_colormap_from_array( colormap=None, normalized_colormap=False, default_colormap="coolwarm" ): from matplotlib.colors import ListedColormap if colormap is None: cmap = _get_cmap(default_colormap) elif isinstance(colormap, str): cmap = _get_cmap(colormap) elif normalized_colormap: cmap = ListedColormap(colormap) else: cmap = ListedColormap(np.array(colormap) / 255.0) return cmap def _check_color(color): from matplotlib.colors import colorConverter if isinstance(color, str): color = colorConverter.to_rgb(color) elif isinstance(color, collections.abc.Iterable): np_color = np.array(color) if np_color.size % 3 != 0 and np_color.size % 4 != 0: raise ValueError("The expected valid format is RGB or RGBA.") if np_color.dtype in (np.int64, np.int32): if (np_color < 0).any() or (np_color > 255).any(): raise ValueError("Values out of range [0, 255].") elif np_color.dtype == np.float64: if (np_color < 0.0).any() or (np_color > 1.0).any(): raise ValueError("Values out of range [0.0, 1.0].") else: raise TypeError( "Expected data type is `np.int64`, `np.int32`, or `np.float64` but " f"{np_color.dtype} was given." ) else: raise TypeError( f"Expected type is `str` or iterable but {type(color)} was given." ) return color def _alpha_blend_background(ctable, background_color): alphas = ctable[:, -1][:, np.newaxis] / 255.0 use_table = ctable.copy() use_table[:, -1] = 255.0 return (use_table * alphas) + background_color * (1 - alphas) @functools.lru_cache(1) def _qt_init_icons(): from qtpy.QtGui import QIcon QIcon.setThemeSearchPaths([str(_ICONS_PATH)] + QIcon.themeSearchPaths()) QIcon.setFallbackThemeName("light") return str(_ICONS_PATH) @contextmanager def _qt_disable_paint(widget): paintEvent = widget.paintEvent widget.paintEvent = lambda *args, **kwargs: None try: yield finally: widget.paintEvent = paintEvent _QT_ICON_KEYS = dict(app=None) def _init_mne_qtapp(enable_icon=True, pg_app=False, splash=False): """Get QApplication-instance for MNE-Python. Parameter --------- enable_icon: bool If to set an MNE-icon for the app. pg_app: bool If to create the QApplication with pyqtgraph. For an until know undiscovered reason the pyqtgraph-browser won't show without mkQApp from pyqtgraph. splash : bool | str If not False, display a splash screen. If str, set the message to the given string. Returns ------- app : ``qtpy.QtWidgets.QApplication`` Instance of QApplication. splash : ``qtpy.QtWidgets.QSplashScreen`` Instance of QSplashScreen. Only returned if splash is True or a string. """ from qtpy.QtCore import Qt from qtpy.QtGui import QGuiApplication, QIcon, QPixmap from qtpy.QtWidgets import QApplication, QSplashScreen app_name = "MNE-Python" organization_name = "MNE" # Fix from cbrnr/mnelab for app name in menu bar # This has to come *before* the creation of the QApplication to work. # It also only affects the title bar, not the application dock. # There seems to be no way to change the application dock from "python" # at runtime. if sys.platform.startswith("darwin"): try: # set bundle name on macOS (app name shown in the menu bar) from Foundation import NSBundle bundle = NSBundle.mainBundle() info = bundle.localizedInfoDictionary() or bundle.infoDictionary() if "CFBundleName" not in info: info["CFBundleName"] = app_name except ModuleNotFoundError: pass # First we need to check to make sure the display is valid, otherwise # Qt might segfault on us app = QApplication.instance() if not (app or _display_is_valid()): raise RuntimeError("Cannot connect to a valid display") if pg_app: from pyqtgraph import mkQApp old_argv = sys.argv try: sys.argv = [] app = mkQApp(app_name) finally: sys.argv = old_argv elif not app: app = QApplication([app_name]) app.setApplicationName(app_name) app.setOrganizationName(organization_name) qt_version = _check_qt_version(check_usable_display=False) # HiDPI is enabled by default in Qt6, requires to be explicitly set for Qt5 if _compare_version(qt_version, "<", "6.0"): app.setAttribute(Qt.AA_UseHighDpiPixmaps) if enable_icon or splash: icons_path = _qt_init_icons() if ( enable_icon and app.windowIcon().cacheKey() != _QT_ICON_KEYS["app"] and app.windowIcon().isNull() # don't overwrite existing icon (e.g. MNELAB) ): # Set icon kind = "bigsur_" if platform.mac_ver()[0] >= "10.16" else "default_" icon = QIcon(f"{icons_path}/mne_{kind}icon.png") app.setWindowIcon(icon) _QT_ICON_KEYS["app"] = app.windowIcon().cacheKey() out = app if splash: pixmap = QPixmap(f"{icons_path}/mne_splash.png") pixmap.setDevicePixelRatio(QGuiApplication.primaryScreen().devicePixelRatio()) args = (pixmap,) if _should_raise_window(): args += (Qt.WindowStaysOnTopHint,) qsplash = QSplashScreen(*args) qsplash.setAttribute(Qt.WA_ShowWithoutActivating, True) if isinstance(splash, str): alignment = int(Qt.AlignBottom | Qt.AlignHCenter) qsplash.showMessage(splash, alignment=alignment, color=Qt.white) qsplash.show() app.processEvents() out = (out, qsplash) return out def _display_is_valid(): # Adapted from matplotilb _c_internal_utils.py if sys.platform != "linux": return True if os.getenv("DISPLAY"): # if it's not there, don't bother libX11 = cdll.LoadLibrary("libX11.so.6") libX11.XOpenDisplay.restype = c_void_p libX11.XOpenDisplay.argtypes = [c_char_p] display = libX11.XOpenDisplay(None) if display is not None: libX11.XCloseDisplay.argtypes = [c_void_p] libX11.XCloseDisplay(display) return True # not found, try Wayland if os.getenv("WAYLAND_DISPLAY"): libwayland = cdll.LoadLibrary("libwayland-client.so.0") if libwayland is not None: if all( hasattr(libwayland, f"wl_display_{kind}connect") for kind in ("", "dis") ): libwayland.wl_display_connect.restype = c_void_p libwayland.wl_display_connect.argtypes = [c_char_p] display = libwayland.wl_display_connect(None) if display: libwayland.wl_display_disconnect.argtypes = [c_void_p] libwayland.wl_display_disconnect(display) return True return False # https://stackoverflow.com/questions/5160577/ctrl-c-doesnt-work-with-pyqt def _qt_app_exec(app): # adapted from matplotlib old_signal = signal.getsignal(signal.SIGINT) is_python_signal_handler = old_signal is not None if is_python_signal_handler: signal.signal(signal.SIGINT, signal.SIG_DFL) try: # Make IPython Console accessible again in Spyder app.lastWindowClosed.connect(app.quit) app.exec_() finally: # reset the SIGINT exception handler if is_python_signal_handler: signal.signal(signal.SIGINT, old_signal) def _qt_detect_theme(): try: import darkdetect theme = darkdetect.theme().lower() except ModuleNotFoundError: logger.info( 'For automatic theme detection, "darkdetect" has to' " be installed! You can install it with " "`pip install darkdetect`" ) theme = "light" except Exception: theme = "light" return theme def _qt_get_stylesheet(theme): _validate_type(theme, ("path-like",), "theme") theme = str(theme) stylesheet = "" # no stylesheet if theme in ("auto", "dark", "light"): if theme == "auto": return stylesheet assert theme in ("dark", "light") system_theme = _qt_detect_theme() if theme == system_theme: return stylesheet _, api = _check_qt_version(return_api=True) # On macOS or Qt 6, we shouldn't need to set anything when the requested # theme matches that of the current OS state try: import qdarkstyle except ModuleNotFoundError: logger.info( f'To use {theme} mode when in {system_theme} mode, "qdarkstyle" has' "to be installed! You can install it with:\n" "pip install qdarkstyle\n" ) else: if api in ("PySide6", "PyQt6") and _compare_version( qdarkstyle.__version__, "<", "3.2.3" ): warn( f"Setting theme={repr(theme)} is not supported for {api} in " f"qdarkstyle {qdarkstyle.__version__}, it will be ignored. " "Consider upgrading qdarkstyle to >=3.2.3." ) else: stylesheet = qdarkstyle.load_stylesheet( getattr( getattr(qdarkstyle, theme).palette, f"{theme.capitalize()}Palette", ) ) return stylesheet else: try: file = open(theme) except OSError: warn( "Requested theme file not found, will use light instead: " f"{repr(theme)}" ) else: with file as fid: stylesheet = fid.read() return stylesheet def _should_raise_window(): from matplotlib import rcParams return rcParams["figure.raise_window"] def _qt_raise_window(widget): # Set raise_window like matplotlib if possible if _should_raise_window(): widget.activateWindow() widget.raise_() def _qt_is_dark(widget): # Ideally this would use CIELab, but this should be good enough win = widget.window() bgcolor = win.palette().color(win.backgroundRole()).getRgbF()[:3] return rgb_to_hls(*bgcolor)[1] < 0.5 def _pixmap_to_ndarray(pixmap): from qtpy.QtGui import QImage img = pixmap.toImage() img = img.convertToFormat(QImage.Format.Format_RGBA8888) ptr = img.bits() count = img.height() * img.width() * 4 if hasattr(ptr, "setsize"): # PyQt ptr.setsize(count) data = np.frombuffer(ptr, dtype=np.uint8, count=count).copy() data.shape = (img.height(), img.width(), 4) return data / 255.0 def _notebook_vtk_works(): if sys.platform != "linux": return True # check if it's OSMesa -- if it is, continue try: from vtkmodules import vtkRenderingOpenGL2 vtkRenderingOpenGL2.vtkOSOpenGLRenderWindow except Exception: pass else: return True # has vtkOSOpenGLRenderWindow (OSMesa build) # if it's not OSMesa, we need to check display validity if _display_is_valid(): return True return False def _qt_safe_window( *, splash="figure.splash", window="figure.plotter.app_window", always_close=True ): def dec(meth, splash=splash, always_close=always_close): @functools.wraps(meth) def func(self, *args, **kwargs): close_splash = always_close error = False try: meth(self, *args, **kwargs) except Exception: close_splash = error = True raise finally: for attr, do_close in ((splash, close_splash), (window, error)): if attr is None or not do_close: continue parent = self name = attr.split(".")[-1] try: for n in attr.split(".")[:-1]: parent = getattr(parent, n) if name: widget = getattr(parent, name, False) else: # empty string means "self" widget = parent if widget: widget.close() del widget except Exception: pass finally: try: delattr(parent, name) except Exception: pass return func return dec