422 lines
13 KiB
Python
422 lines
13 KiB
Python
#
|
|
# 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
|