diff options
Diffstat (limited to 'src/silx/gui/utils')
-rwxr-xr-x | src/silx/gui/utils/__init__.py | 76 | ||||
-rw-r--r-- | src/silx/gui/utils/concurrent.py | 105 | ||||
-rw-r--r-- | src/silx/gui/utils/glutils/__init__.py | 199 | ||||
-rw-r--r-- | src/silx/gui/utils/image.py | 143 | ||||
-rw-r--r-- | src/silx/gui/utils/matplotlib.py | 65 | ||||
-rw-r--r-- | src/silx/gui/utils/projecturl.py | 77 | ||||
-rwxr-xr-x | src/silx/gui/utils/qtutils.py | 196 | ||||
-rw-r--r-- | src/silx/gui/utils/signal.py | 141 | ||||
-rwxr-xr-x | src/silx/gui/utils/test/__init__.py | 25 | ||||
-rw-r--r-- | src/silx/gui/utils/test/test.py | 63 | ||||
-rw-r--r-- | src/silx/gui/utils/test/test_async.py | 127 | ||||
-rw-r--r-- | src/silx/gui/utils/test/test_glutils.py | 55 | ||||
-rw-r--r-- | src/silx/gui/utils/test/test_image.py | 79 | ||||
-rwxr-xr-x | src/silx/gui/utils/test/test_qtutils.py | 65 | ||||
-rw-r--r-- | src/silx/gui/utils/test/test_testutils.py | 44 | ||||
-rw-r--r-- | src/silx/gui/utils/testutils.py | 508 |
16 files changed, 1968 insertions, 0 deletions
diff --git a/src/silx/gui/utils/__init__.py b/src/silx/gui/utils/__init__.py new file mode 100755 index 0000000..726ad74 --- /dev/null +++ b/src/silx/gui/utils/__init__.py @@ -0,0 +1,76 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Miscellaneous helpers for Qt""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "09/03/2018" + + +import contextlib as _contextlib + + +@_contextlib.contextmanager +def blockSignals(*objs): + """Context manager blocking signals of QObjects. + + It restores previous state when leaving. + + :param qt.QObject objs: QObjects for which to block signals + """ + blocked = [(obj, obj.blockSignals(True)) for obj in objs] + try: + yield + finally: + for obj, previous in blocked: + obj.blockSignals(previous) + + +class LockReentrant(): + """Context manager to lock a code block and check the state. + """ + def __init__(self): + self.__locked = False + + def __enter__(self): + self.__locked = True + + def __exit__(self, exc_type, exc_val, exc_tb): + self.__locked = False + + def locked(self): + """Returns True if the code block is locked""" + return self.__locked + + +def getQEventName(eventType): + """ + Returns the name of a QEvent. + + :param Union[int,qt.QEvent] eventType: A QEvent or a QEvent type. + :returns: str + """ + from . import qtutils + return qtutils.getQEventName(eventType) diff --git a/src/silx/gui/utils/concurrent.py b/src/silx/gui/utils/concurrent.py new file mode 100644 index 0000000..c27374f --- /dev/null +++ b/src/silx/gui/utils/concurrent.py @@ -0,0 +1,105 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module allows to run a function in Qt main thread from another thread +""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "09/03/2018" + + +from concurrent.futures import Future + +from .. import qt + + +class _QtExecutor(qt.QObject): + """Executor of tasks in Qt main thread""" + + __sigSubmit = qt.Signal(Future, object, tuple, dict) + """Signal used to run tasks.""" + + def __init__(self): + super(_QtExecutor, self).__init__(parent=None) + + # Makes sure the executor lives in the main thread + app = qt.QApplication.instance() + assert app is not None + mainThread = app.thread() + if self.thread() != mainThread: + self.moveToThread(mainThread) + + self.__sigSubmit.connect(self.__run) + + def submit(self, fn, *args, **kwargs): + """Submit fn(*args, **kwargs) to Qt main thread + + :param callable fn: Function to call in main thread + :return: Future object to retrieve result + :rtype: concurrent.future.Future + """ + future = Future() + self.__sigSubmit.emit(future, fn, args, kwargs) + return future + + def __run(self, future, fn, args, kwargs): + """Run task in Qt main thread + + :param concurrent.future.Future future: + :param callable fn: Function to run + :param tuple args: Arguments + :param dict kwargs: Keyword arguments + """ + if not future.set_running_or_notify_cancel(): + return + + try: + result = fn(*args, **kwargs) + except BaseException as e: + future.set_exception(e) + else: + future.set_result(result) + + +_executor = None +"""QObject running the tasks in main thread""" + + +def submitToQtMainThread(fn, *args, **kwargs): + """Run fn(args, kwargs) in Qt's main thread. + + If not called from the main thread, this is run asynchronously. + + :param callable fn: Function to call in main thread. + :return: A future object to retrieve the result + :rtype: concurrent.future.Future + """ + global _executor + if _executor is None: # Lazy-loading + _executor = _QtExecutor() + + return _executor.submit(fn, *args, **kwargs) diff --git a/src/silx/gui/utils/glutils/__init__.py b/src/silx/gui/utils/glutils/__init__.py new file mode 100644 index 0000000..20e611e --- /dev/null +++ b/src/silx/gui/utils/glutils/__init__.py @@ -0,0 +1,199 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2020-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides the :func:`isOpenGLAvailable` utility function. +""" + +import os +import sys +import subprocess +from silx.gui import qt + + +class _isOpenGLAvailableResult: + """Store result of checking OpenGL availability. + + It provides a `status` boolean attribute storing the result of the check and + an `error` string attribute storting the possible error message. + """ + + def __init__(self, status=True, error=''): + self.__status = bool(status) + self.__error = str(error) + + status = property(lambda self: self.__status, doc="True if OpenGL is working") + error = property(lambda self: self.__error, doc="Error message") + + def __bool__(self): + return self.status + + def __repr__(self): + return '<_isOpenGLAvailableResult: %s, "%s">' % (self.status, self.error) + + +def _runtimeOpenGLCheck(version): + """Run OpenGL check in a subprocess. + + This is done by starting a subprocess that displays a Qt OpenGL widget. + + :param List[int] version: + The minimal required OpenGL version as a 2-tuple (major, minor). + Default: (2, 1) + :return: An error string that is empty if no error occured + :rtype: str + """ + major, minor = str(version[0]), str(version[1]) + env = os.environ.copy() + env['PYTHONPATH'] = os.pathsep.join( + [os.path.abspath(p) for p in sys.path]) + + try: + error = subprocess.check_output( + [sys.executable, '-s', '-S', __file__, major, minor], + env=env, + timeout=2) + except subprocess.TimeoutExpired: + status = False + error = "Qt OpenGL widget hang" + if sys.platform.startswith('linux'): + error += ':\nIf connected remotely, GLX forwarding might be disabled.' + except subprocess.CalledProcessError as e: + status = False + error = "Qt OpenGL widget error: retcode=%d, error=%s" % (e.returncode, e.output) + else: + status = True + error = error.decode() + return _isOpenGLAvailableResult(status, error) + + +_runtimeCheckCache = {} # Cache runtime check results: {version: result} + + +def isOpenGLAvailable(version=(2, 1), runtimeCheck=True): + """Check if OpenGL is available through Qt and actually working. + + After some basic tests, this is done by starting a subprocess that + displays a Qt OpenGL widget. + + :param List[int] version: + The minimal required OpenGL version as a 2-tuple (major, minor). + Default: (2, 1) + :param bool runtimeCheck: + True (default) to run the test creating a Qt OpenGL widgt in a subprocess, + False to avoid this check. + :return: A result object that evaluates to True if successful and + which has a `status` boolean attribute (True if successful) and + an `error` string attribute that is not empty if `status` is False. + """ + error = '' + + if sys.platform.startswith('linux') and not os.environ.get('DISPLAY', ''): + # On Linux and no DISPLAY available (e.g., ssh without -X) + error = 'DISPLAY environment variable not set' + + else: + # Check pyopengl availability + try: + import silx.gui._glutils.gl # noqa + except ImportError: + error = "Cannot import OpenGL wrapper: pyopengl is not installed" + else: + # Pre checks for Qt < 5.4 + if not hasattr(qt, 'QOpenGLWidget'): + if not qt.HAS_OPENGL: + error = '%s.QtOpenGL not available' % qt.BINDING + + elif qt.BINDING in ('PySide2', 'PyQt5') and qt.QApplication.instance() and not qt.QGLFormat.hasOpenGL(): + # qt.QGLFormat.hasOpenGL MUST be called with a QApplication created + # so this is only checked if the QApplication is already created + error = 'Qt reports OpenGL not available' + + result = _isOpenGLAvailableResult(error == '', error) + + if result: # No error so far, runtime check + if version in _runtimeCheckCache: # Use cache + result = _runtimeCheckCache[version] + elif runtimeCheck: # Run test in subprocess + result = _runtimeOpenGLCheck(version) + _runtimeCheckCache[version] = result + + return result + + +if __name__ == "__main__": + from silx.gui._glutils import OpenGLWidget + from silx.gui._glutils import gl + import argparse + + class _TestOpenGLWidget(OpenGLWidget): + """Widget checking that OpenGL is indeed available + + :param List[int] version: (major, minor) minimum OpenGL version + """ + + def __init__(self, version): + super(_TestOpenGLWidget, self).__init__( + alphaBufferSize=0, + depthBufferSize=0, + stencilBufferSize=0, + version=version) + + def paintEvent(self, event): + super(_TestOpenGLWidget, self).paintEvent(event) + + # Check once paint has been done + app = qt.QApplication.instance() + if not self.isValid(): + print("OpenGL widget is not valid") + app.exit(1) + else: + qt.QTimer.singleShot(100, app.quit) + + def paintGL(self): + gl.glClearColor(1., 0., 0., 0.) + gl.glClear(gl.GL_COLOR_BUFFER_BIT) + + + parser = argparse.ArgumentParser() + parser.add_argument('major') + parser.add_argument('minor') + + args = parser.parse_args(args=sys.argv[1:]) + + app = qt.QApplication([]) + window = qt.QMainWindow(flags= + qt.Qt.Popup | + qt.Qt.FramelessWindowHint | + qt.Qt.NoDropShadowWindowHint | + qt.Qt.WindowStaysOnTopHint) + window.setAttribute(qt.Qt.WA_ShowWithoutActivating) + window.move(0, 0) + window.resize(3, 3) + widget = _TestOpenGLWidget(version=(args.major, args.minor)) + window.setCentralWidget(widget) + window.setWindowOpacity(0.04) + window.show() + + qt.QTimer.singleShot(1000, app.quit) + sys.exit(app.exec()) diff --git a/src/silx/gui/utils/image.py b/src/silx/gui/utils/image.py new file mode 100644 index 0000000..96f50ab --- /dev/null +++ b/src/silx/gui/utils/image.py @@ -0,0 +1,143 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module provides conversions between numpy.ndarray and QImage + +- :func:`convertArrayToQImage` +- :func:`convertQImageToArray` +""" + +from __future__ import division + + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "04/09/2018" + + +import sys +import numpy +from numpy.lib.stride_tricks import as_strided as _as_strided + +from .. import qt + + +def convertArrayToQImage(array): + """Convert an array-like image to a QImage. + + The created QImage is using a copy of the array data. + + Limitation: Only RGB or RGBA images with 8 bits per channel are supported. + + :param array: Array-like image data of shape (height, width, channels) + Channels are expected to be either RGB or RGBA. + :type array: numpy.ndarray of uint8 + :return: Corresponding Qt image with RGB888 or ARGB32 format. + :rtype: QImage + """ + array = numpy.array(array, copy=False, order='C', dtype=numpy.uint8) + + if array.ndim != 3 or array.shape[2] not in (3, 4): + raise ValueError( + 'Image must be a 3D array with 3 or 4 channels per pixel') + + if array.shape[2] == 4: + format_ = qt.QImage.Format_ARGB32 + # RGBA -> ARGB + take care of endianness + if sys.byteorder == 'little': # RGBA -> BGRA + array = array[:, :, (2, 1, 0, 3)] + else: # big endian: RGBA -> ARGB + array = array[:, :, (3, 0, 1, 2)] + + array = numpy.array(array, order='C') # Make a contiguous array + + else: # array.shape[2] == 3 + format_ = qt.QImage.Format_RGB888 + + height, width, depth = array.shape + qimage = qt.QImage( + array.data, + width, + height, + array.strides[0], # bytesPerLine + format_) + + return qimage.copy() # Making a copy of the image and its data + + +def convertQImageToArray(image): + """Convert a QImage to a numpy array. + + If QImage format is not Format_RGB888, Format_RGBA8888 or Format_ARGB32, + it is first converted to one of this format depending on + the presence of an alpha channel. + + The created numpy array is using a copy of the QImage data. + + :param QImage image: The QImage to convert. + :return: The image array of RGB or RGBA channels of shape + (height, width, channels (3 or 4)) + :rtype: numpy.ndarray of uint8 + """ + rgba8888 = getattr(qt.QImage, 'Format_RGBA8888', None) # Only in Qt5 + + # Convert to supported format if needed + if image.format() not in (qt.QImage.Format_ARGB32, + qt.QImage.Format_RGB888, + rgba8888): + if image.hasAlphaChannel(): + image = image.convertToFormat( + rgba8888 if rgba8888 is not None else qt.QImage.Format_ARGB32) + else: + image = image.convertToFormat(qt.QImage.Format_RGB888) + + format_ = image.format() + channels = 3 if format_ == qt.QImage.Format_RGB888 else 4 + + ptr = image.bits() + if qt.BINDING == 'PyQt5': + ptr.setsize(image.byteCount()) + elif qt.BINDING in ('PySide2', 'PySide6'): + ptr = ptr.tobytes() + else: + raise RuntimeError("Unsupported Qt binding: %s" % qt.BINDING) + + # Create an array view on QImage internal data + view = _as_strided( + numpy.frombuffer(ptr, dtype=numpy.uint8), + shape=(image.height(), image.width(), channels), + strides=(image.bytesPerLine(), channels, 1)) + + if format_ == qt.QImage.Format_ARGB32: + # Convert from ARGB to RGBA + # Not a byte-ordered format: do care about endianness + if sys.byteorder == 'little': # BGRA -> RGBA + view = view[:, :, (2, 1, 0, 3)] + else: # big endian: ARGB -> RGBA + view = view[:, :, (1, 2, 3, 0)] + + # Format_RGB888 and Format_RGBA8888 do not need reshuffling channels: + # They are byte-ordered and already in the right order + + return numpy.array(view, copy=True, order='C') diff --git a/src/silx/gui/utils/matplotlib.py b/src/silx/gui/utils/matplotlib.py new file mode 100644 index 0000000..90257f8 --- /dev/null +++ b/src/silx/gui/utils/matplotlib.py @@ -0,0 +1,65 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ + +from __future__ import absolute_import + +"""This module initializes matplotlib and sets-up the backend to use. + +It MUST be imported prior to any other import of matplotlib. + +It provides the matplotlib :class:`FigureCanvasQTAgg` class corresponding +to the used backend. +""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "02/05/2018" + + +from pkg_resources import parse_version +import matplotlib + +from .. import qt + + +def _matplotlib_use(backend, force): + """Wrapper of `matplotlib.use` to set-up backend. + + It adds extra initialization for PySide2 with matplotlib < 2.2. + """ + # This is kept for compatibility with matplotlib < 2.2 + if (parse_version(matplotlib.__version__) < parse_version('2.2') and + qt.BINDING == 'PySide2'): + matplotlib.rcParams['backend.qt5'] = 'PySide2' + + matplotlib.use(backend, force=force) + + +if qt.BINDING in ('PySide6', 'PyQt5', 'PySide2'): + _matplotlib_use('Qt5Agg', force=False) + from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg # noqa + +else: + raise ImportError("Unsupported Qt binding: %s" % qt.BINDING) diff --git a/src/silx/gui/utils/projecturl.py b/src/silx/gui/utils/projecturl.py new file mode 100644 index 0000000..0832c2e --- /dev/null +++ b/src/silx/gui/utils/projecturl.py @@ -0,0 +1,77 @@ +# coding: utf-8 +# +# Project: Azimuthal integration +# https://github.com/silx-kit/silx +# +# Copyright (C) 2015-2019 European Synchrotron Radiation Facility, Grenoble, France +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from __future__ import absolute_import, print_function, division + +"""Provide convenient URL for silx-kit projects.""" + +__author__ = "Valentin Valls" +__contact__ = "valentin.valls@ESRF.eu" +__license__ = "MIT" +__copyright__ = "European Synchrotron Radiation Facility, Grenoble, France" +__date__ = "15/01/2019" + + +from ... import _version as version + +BASE_DOC_URL = None +"""This could be patched by project packagers.""" + +_DEFAULT_BASE_DOC_URL = "http://www.silx.org/pub/doc/silx/{silx_doc_version}/{subpath}" +"""Identify the base URL of the project documentation. + +It supportes string replacement: + +- `{major}` the major version +- `{minor}` the minor version +- `{micro}` the micro version +- `{relev}` the status of the version (dev, final, rc). +- `{silx_doc_version}` is used to map the documentation stored at www.silx.org +- `{subpath}` is the subpart of the URL pointing to a specific page of the + documentation. It is mandatory. +""" + + +def getDocumentationUrl(subpath): + """Returns the URL to the documentation""" + + if version.RELEV == "final": + # Released verison will point to a specific documentation + silx_doc_version = "%d.%d.%d" % (version.MAJOR, version.MINOR, version.MICRO) + else: + # Dev versions will point to a single 'dev' documentation + silx_doc_version = "dev" + + keyworks = { + "silx_doc_version": silx_doc_version, + "major": version.MAJOR, + "minor": version.MINOR, + "micro": version.MICRO, + "relev": version.RELEV, + "subpath": subpath} + template = BASE_DOC_URL + if template is None: + template = _DEFAULT_BASE_DOC_URL + return template.format(**keyworks) diff --git a/src/silx/gui/utils/qtutils.py b/src/silx/gui/utils/qtutils.py new file mode 100755 index 0000000..9682913 --- /dev/null +++ b/src/silx/gui/utils/qtutils.py @@ -0,0 +1,196 @@ +# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2020 European Synchrotron Radiation Facility
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+# ###########################################################################*/
+"""This module provides the :func:`getQEventName` utility function."""
+
+from silx.gui import qt
+
+
+QT_EVENT_NAMES = {
+ 0: "None",
+ 114: "ActionAdded",
+ 113: "ActionChanged",
+ 115: "ActionRemoved",
+ 99: "ActivationChange",
+ 121: "ApplicationActivate",
+ # ApplicationActivate: "ApplicationActivated",
+ 122: "ApplicationDeactivate",
+ 36: "ApplicationFontChange",
+ 37: "ApplicationLayoutDirectionChange",
+ 38: "ApplicationPaletteChange",
+ 214: "ApplicationStateChange",
+ 35: "ApplicationWindowIconChange",
+ 68: "ChildAdded",
+ 69: "ChildPolished",
+ 71: "ChildRemoved",
+ 40: "Clipboard",
+ 19: "Close",
+ 200: "CloseSoftwareInputPanel",
+ 178: "ContentsRectChange",
+ 82: "ContextMenu",
+ 183: "CursorChange",
+ 52: "DeferredDelete",
+ 60: "DragEnter",
+ 62: "DragLeave",
+ 61: "DragMove",
+ 63: "Drop",
+ 170: "DynamicPropertyChange",
+ 98: "EnabledChange",
+ 10: "Enter",
+ 150: "EnterEditFocus",
+ 124: "EnterWhatsThisMode",
+ 206: "Expose",
+ 116: "FileOpen",
+ 8: "FocusIn",
+ 9: "FocusOut",
+ 23: "FocusAboutToChange",
+ 97: "FontChange",
+ 198: "Gesture",
+ 202: "GestureOverride",
+ 188: "GrabKeyboard",
+ 186: "GrabMouse",
+ 159: "GraphicsSceneContextMenu",
+ 164: "GraphicsSceneDragEnter",
+ 166: "GraphicsSceneDragLeave",
+ 165: "GraphicsSceneDragMove",
+ 167: "GraphicsSceneDrop",
+ 163: "GraphicsSceneHelp",
+ 160: "GraphicsSceneHoverEnter",
+ 162: "GraphicsSceneHoverLeave",
+ 161: "GraphicsSceneHoverMove",
+ 158: "GraphicsSceneMouseDoubleClick",
+ 155: "GraphicsSceneMouseMove",
+ 156: "GraphicsSceneMousePress",
+ 157: "GraphicsSceneMouseRelease",
+ 182: "GraphicsSceneMove",
+ 181: "GraphicsSceneResize",
+ 168: "GraphicsSceneWheel",
+ 18: "Hide",
+ 27: "HideToParent",
+ 127: "HoverEnter",
+ 128: "HoverLeave",
+ 129: "HoverMove",
+ 96: "IconDrag",
+ 101: "IconTextChange",
+ 83: "InputMethod",
+ 207: "InputMethodQuery",
+ 169: "KeyboardLayoutChange",
+ 6: "KeyPress",
+ 7: "KeyRelease",
+ 89: "LanguageChange",
+ 90: "LayoutDirectionChange",
+ 76: "LayoutRequest",
+ 11: "Leave",
+ 151: "LeaveEditFocus",
+ 125: "LeaveWhatsThisMode",
+ 88: "LocaleChange",
+ 176: "NonClientAreaMouseButtonDblClick",
+ 174: "NonClientAreaMouseButtonPress",
+ 175: "NonClientAreaMouseButtonRelease",
+ 173: "NonClientAreaMouseMove",
+ 177: "MacSizeChange",
+ 43: "MetaCall",
+ 102: "ModifiedChange",
+ 4: "MouseButtonDblClick",
+ 2: "MouseButtonPress",
+ 3: "MouseButtonRelease",
+ 5: "MouseMove",
+ 109: "MouseTrackingChange",
+ 13: "Move",
+ 197: "NativeGesture",
+ 208: "OrientationChange",
+ 12: "Paint",
+ 39: "PaletteChange",
+ 131: "ParentAboutToChange",
+ 21: "ParentChange",
+ 212: "PlatformPanel",
+ 217: "PlatformSurface",
+ 75: "Polish",
+ 74: "PolishRequest",
+ 123: "QueryWhatsThis",
+ 106: "ReadOnlyChange",
+ 199: "RequestSoftwareInputPanel",
+ 14: "Resize",
+ 204: "ScrollPrepare",
+ 205: "Scroll",
+ 117: "Shortcut",
+ 51: "ShortcutOverride",
+ 17: "Show",
+ 26: "ShowToParent",
+ 50: "SockAct",
+ 192: "StateMachineSignal",
+ 193: "StateMachineWrapped",
+ 112: "StatusTip",
+ 100: "StyleChange",
+ 87: "TabletMove",
+ 92: "TabletPress",
+ 93: "TabletRelease",
+ 171: "TabletEnterProximity",
+ 172: "TabletLeaveProximity",
+ 219: "TabletTrackingChange",
+ 22: "ThreadChange",
+ 1: "Timer",
+ 120: "ToolBarChange",
+ 110: "ToolTip",
+ 184: "ToolTipChange",
+ 194: "TouchBegin",
+ 209: "TouchCancel",
+ 196: "TouchEnd",
+ 195: "TouchUpdate",
+ 189: "UngrabKeyboard",
+ 187: "UngrabMouse",
+ 78: "UpdateLater",
+ 77: "UpdateRequest",
+ 111: "WhatsThis",
+ 118: "WhatsThisClicked",
+ 31: "Wheel",
+ 132: "WinEventAct",
+ 24: "WindowActivate",
+ 103: "WindowBlocked",
+ 25: "WindowDeactivate",
+ 34: "WindowIconChange",
+ 105: "WindowStateChange",
+ 33: "WindowTitleChange",
+ 104: "WindowUnblocked",
+ 203: "WinIdChange",
+ 126: "ZOrderChange",
+ 65535: "MaxUser",
+}
+
+
+def getQEventName(eventType):
+ """
+ Returns the name of a QEvent.
+
+ :param Union[int,qt.QEvent] eventType: A QEvent or a QEvent type.
+ :returns: str
+ """
+ if isinstance(eventType, qt.QEvent):
+ eventType = eventType.type()
+ if 1000 <= eventType <= 65535:
+ return "User_%d" % eventType
+ name = QT_EVENT_NAMES.get(eventType, None)
+ if name is not None:
+ return name
+ return "Unknown_%d" % eventType
diff --git a/src/silx/gui/utils/signal.py b/src/silx/gui/utils/signal.py new file mode 100644 index 0000000..359f5cc --- /dev/null +++ b/src/silx/gui/utils/signal.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2012 University of North Carolina at Chapel Hill, Luke Campagnola +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""This module contains utils relative to qt Signal +""" + +from silx.gui import qt +import weakref +from time import time +from silx.gui.utils import concurrent + +__all__ = ['SignalProxy'] +__authors__ = ['L. Campagnola', 'M. Liberty'] +__license__ = "MIT" + + +class SignalProxy(qt.QObject): + """ + This peace of code come from pyqtgraph + Object which collects rapid-fire signals and condenses them + into a single signal or a rate-limited stream of signals. + Used, for example, to prevent a SpinBox from generating multiple + signals when the mouse wheel is rolled over it. + + Emits sigDelayed after input signals have stopped for a certain period of time. + """ + + sigDelayed = qt.Signal(object) + + def __init__(self, signal, delay=0.3, rateLimit=0, slot=None): + """Initialization arguments: + signal - a bound Signal or pyqtSignal instance + delay - Time (in seconds) to wait for signals to stop before emitting (default 0.3s) + slot - Optional function to connect sigDelayed to. + rateLimit - (signals/sec) if greater than 0, this allows signals to stream out at a + steady rate while they are being received. + """ + + qt.QObject.__init__(self) + signal.connect(self.signalReceived) + self.signal = signal + self.delay = delay + self.rateLimit = rateLimit + self.args = None + self.timer = qt.QTimer() + self.timer.timeout.connect(self.flush) + self.blockSignal = False + self.slot = weakref.ref(slot) + self.lastFlushTime = None + if slot is not None: + self.sigDelayed.connect(slot) + + def setDelay(self, delay): + self.delay = delay + + def signalReceived(self, *args): + """Received signal. Cancel previous timer and store args to be forwarded later.""" + if self.blockSignal: + return + self.args = args + if self.rateLimit == 0: + concurrent.submitToQtMainThread(self.timer.stop) + concurrent.submitToQtMainThread(self.timer.start, (self.delay * 1000) + 1) + else: + now = time() + if self.lastFlushTime is None: + leakTime = 0 + else: + lastFlush = self.lastFlushTime + leakTime = max(0, (lastFlush + (1.0 / self.rateLimit)) - now) + + concurrent.submitToQtMainThread(self.timer.stop) + concurrent.submitToQtMainThread(self.timer.start, (min(leakTime, self.delay) * 1000) + 1) + # self.timer.stop() + # self.timer.start((min(leakTime, self.delay) * 1000) + 1) + + def flush(self): + """If there is a signal queued up, send it now.""" + if self.args is None or self.blockSignal: + return False + args, self.args = self.args, None + concurrent.submitToQtMainThread(self.timer.stop) + self.lastFlushTime = time() + # self.emit(self.signal, *self.args) + concurrent.submitToQtMainThread(self.sigDelayed.emit, args) + # self.sigDelayed.emit(args) + return True + + def disconnect(self): + self.blockSignal = True + try: + self.signal.disconnect(self.signalReceived) + except: + pass + try: + self.sigDelayed.disconnect(self.slot) + except: + pass + + +if __name__ == '__main__': + app = qt.QApplication([]) + win = qt.QMainWindow() + spin = qt.QSpinBox() + win.setCentralWidget(spin) + win.show() + + + def fn(*args): + print("Raw signal:", args) + + + def fn2(*args): + print("Delayed signal:", args) + + + spin.valueChanged.connect(fn) + # proxy = proxyConnect(spin, QtCore.SIGNAL('valueChanged(int)'), fn) + proxy = SignalProxy(spin.valueChanged, delay=0.5, slot=fn2) diff --git a/src/silx/gui/utils/test/__init__.py b/src/silx/gui/utils/test/__init__.py new file mode 100755 index 0000000..15cd186 --- /dev/null +++ b/src/silx/gui/utils/test/__init__.py @@ -0,0 +1,25 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018-2020 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""silx.gui.utils tests""" diff --git a/src/silx/gui/utils/test/test.py b/src/silx/gui/utils/test/test.py new file mode 100644 index 0000000..0208d64 --- /dev/null +++ b/src/silx/gui/utils/test/test.py @@ -0,0 +1,63 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2019-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Test of functions available in silx.gui.utils module.""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "01/08/2019" + + +import unittest +from silx.gui import qt +from silx.gui.utils.testutils import TestCaseQt, SignalListener + +from silx.gui.utils import blockSignals + + +class TestBlockSignals(TestCaseQt): + """Test blockSignals context manager""" + + def _test(self, *objs): + """Test for provided objects""" + listener = SignalListener() + for obj in objs: + obj.objectNameChanged.connect(listener) + obj.setObjectName("received") + + with blockSignals(*objs): + for obj in objs: + obj.setObjectName("silent") + + self.assertEqual(listener.arguments(), [("received",)] * len(objs)) + + def testManyObjects(self): + """Test blockSignals with 2 QObjects""" + self._test(qt.QObject(), qt.QObject()) + + def testOneObject(self): + """Test blockSignals context manager with a single QObject""" + self._test(qt.QObject()) diff --git a/src/silx/gui/utils/test/test_async.py b/src/silx/gui/utils/test/test_async.py new file mode 100644 index 0000000..7304ca9 --- /dev/null +++ b/src/silx/gui/utils/test/test_async.py @@ -0,0 +1,127 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Test of async module.""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "09/03/2018" + + +import threading +import unittest + + +from concurrent.futures import wait +from silx.gui import qt +from silx.gui.utils.testutils import TestCaseQt + +from silx.gui.utils import concurrent + + +class TestSubmitToQtThread(TestCaseQt): + """Test submission of tasks to Qt main thread""" + + def setUp(self): + # Reset executor to test lazy-loading in different conditions + concurrent._executor = None + super(TestSubmitToQtThread, self).setUp() + + def _task(self, value1, value2): + return value1, value2 + + def _taskWithException(self, *args, **kwargs): + raise RuntimeError('task exception') + + def testFromMainThread(self): + """Call submitToQtMainThread from the main thread""" + value1, value2 = 0, 1 + future = concurrent.submitToQtMainThread(self._task, value1, value2=value2) + self.assertTrue(future.done()) + self.assertEqual(future.result(1), (value1, value2)) + self.assertIsNone(future.exception(1)) + + future = concurrent.submitToQtMainThread(self._taskWithException) + self.assertTrue(future.done()) + with self.assertRaises(RuntimeError): + future.result(1) + self.assertIsInstance(future.exception(1), RuntimeError) + + def _threadedTest(self): + """Function run in a thread for the tests""" + value1, value2 = 0, 1 + future = concurrent.submitToQtMainThread(self._task, value1, value2=value2) + + wait([future], 3) + + self.assertTrue(future.done()) + self.assertEqual(future.result(1), (value1, value2)) + self.assertIsNone(future.exception(1)) + + future = concurrent.submitToQtMainThread(self._taskWithException) + + wait([future], 3) + + self.assertTrue(future.done()) + with self.assertRaises(RuntimeError): + future.result(1) + self.assertIsInstance(future.exception(1), RuntimeError) + + def testFromPythonThread(self): + """Call submitToQtMainThread from a Python thread""" + thread = threading.Thread(target=self._threadedTest) + thread.start() + for i in range(100): # Loop over for 10 seconds + self.qapp.processEvents() + thread.join(0.1) + if not thread.is_alive(): + break + else: + self.fail(('Thread task still running')) + + def testFromQtThread(self): + """Call submitToQtMainThread from a Qt thread pool""" + class Runner(qt.QRunnable): + def __init__(self, fn): + super(Runner, self).__init__() + self._fn = fn + + def run(self): + self._fn() + + def autoDelete(self): + return True + + threadPool = qt.silxGlobalThreadPool() + runner = Runner(self._threadedTest) + threadPool.start(runner) + for i in range(100): # Loop over for 10 seconds + self.qapp.processEvents() + done = threadPool.waitForDone(100) + if done: + break + else: + self.fail('Thread pool task still running') diff --git a/src/silx/gui/utils/test/test_glutils.py b/src/silx/gui/utils/test/test_glutils.py new file mode 100644 index 0000000..7c9831b --- /dev/null +++ b/src/silx/gui/utils/test/test_glutils.py @@ -0,0 +1,55 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2020 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Tests for the silx.gui.utils.glutils module.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "15/01/2020" + + +import logging +import unittest +from silx.gui.utils.glutils import isOpenGLAvailable + + +_logger = logging.getLogger(__name__) + + +class TestIsOpenGLAvailable(unittest.TestCase): + """Test isOpenGLAvailable""" + + def test(self): + for version in ((2, 1), (2, 1), (1000, 1)): + with self.subTest(version=version): + result = isOpenGLAvailable(version=version) + _logger.info("isOpenGLAvailable returned: %s", str(result)) + if version[0] == 1000: + self.assertFalse(result) + if not result: + self.assertFalse(result.status) + self.assertTrue(len(result.error) > 0) + else: + self.assertTrue(result.status) + self.assertTrue(len(result.error) == 0) diff --git a/src/silx/gui/utils/test/test_image.py b/src/silx/gui/utils/test/test_image.py new file mode 100644 index 0000000..62316b0 --- /dev/null +++ b/src/silx/gui/utils/test/test_image.py @@ -0,0 +1,79 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2018 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Test of utils module.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "16/01/2017" + +import numpy +import unittest + +from silx.gui import qt +from silx.utils.testutils import ParametricTestCase +from silx.gui.utils.testutils import TestCaseQt +from silx.gui.utils.image import convertArrayToQImage, convertQImageToArray + + +class TestQImageConversion(TestCaseQt, ParametricTestCase): + """Tests conversion of QImage to/from numpy array.""" + + def testConvertArrayToQImage(self): + """Test conversion of numpy array to QImage""" + for format_, channels in [('Format_RGB888', 3), + ('Format_ARGB32', 4)]: + with self.subTest(format_): + image = numpy.arange( + 3*3*channels, dtype=numpy.uint8).reshape(3, 3, channels) + qimage = convertArrayToQImage(image) + + self.assertEqual(qimage.height(), image.shape[0]) + self.assertEqual(qimage.width(), image.shape[1]) + self.assertEqual(qimage.format(), getattr(qt.QImage, format_)) + + for row in range(3): + for col in range(3): + # Qrgb has no alpha channel, not compared + # Qt uses x,y while array is row,col... + self.assertEqual(qt.QColor(qimage.pixel(col, row)), + qt.QColor(*image[row, col, :3])) + + + def testConvertQImageToArray(self): + """Test conversion of QImage to numpy array""" + for format_, channels in [ + ('Format_RGB888', 3), # Native support + ('Format_ARGB32', 4), # Native support + ('Format_RGB32', 3)]: # Conversion to RGB + with self.subTest(format_): + color = numpy.arange(channels) # RGB(A) values + qimage = qt.QImage(3, 3, getattr(qt.QImage, format_)) + qimage.fill(qt.QColor(*color)) + image = convertQImageToArray(qimage) + + self.assertEqual(qimage.height(), image.shape[0]) + self.assertEqual(qimage.width(), image.shape[1]) + self.assertEqual(image.shape[2], len(color)) + self.assertTrue(numpy.all(numpy.equal(image, color))) diff --git a/src/silx/gui/utils/test/test_qtutils.py b/src/silx/gui/utils/test/test_qtutils.py new file mode 100755 index 0000000..c00280b --- /dev/null +++ b/src/silx/gui/utils/test/test_qtutils.py @@ -0,0 +1,65 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Test of functions available in silx.gui.utils module.""" + +from __future__ import absolute_import + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "01/08/2019" + + +import unittest +from silx.gui import qt +from silx.gui import utils +from silx.gui.utils.testutils import TestCaseQt + + +class TestQEventName(TestCaseQt): + """Test QEvent names""" + + def testNoneType(self): + result = utils.getQEventName(0) + self.assertEqual(result, "None") + + def testNoneEvent(self): + event = qt.QEvent(qt.QEvent.Type(0)) + result = utils.getQEventName(event) + self.assertEqual(result, "None") + + def testUserType(self): + result = utils.getQEventName(1050) + self.assertIn("User", result) + self.assertIn("1050", result) + + def testQtUndefinedType(self): + result = utils.getQEventName(900) + self.assertIn("Unknown", result) + self.assertIn("900", result) + + def testUndefinedType(self): + result = utils.getQEventName(70000) + self.assertIn("Unknown", result) + self.assertIn("70000", result) diff --git a/src/silx/gui/utils/test/test_testutils.py b/src/silx/gui/utils/test/test_testutils.py new file mode 100644 index 0000000..07294a7 --- /dev/null +++ b/src/silx/gui/utils/test/test_testutils.py @@ -0,0 +1,44 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2017-2019 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Test of testutils module.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "16/01/2017" + +import unittest +import sys + +from silx.gui import qt +from ..testutils import TestCaseQt + + +class TestOutcome(unittest.TestCase): + """Tests conversion of QImage to/from numpy array.""" + + @unittest.skipIf(sys.version_info.major <= 2, 'Python3 only') + def testNoneOutcome(self): + test = TestCaseQt() + test._currentTestSucceeded() diff --git a/src/silx/gui/utils/testutils.py b/src/silx/gui/utils/testutils.py new file mode 100644 index 0000000..40c8237 --- /dev/null +++ b/src/silx/gui/utils/testutils.py @@ -0,0 +1,508 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Helper class to write Qt widget unittests.""" + +__authors__ = ["T. Vincent"] +__license__ = "MIT" +__date__ = "05/10/2018" + + +import gc +import logging +import unittest +import time +import functools +import sys +import os + +_logger = logging.getLogger(__name__) + +from silx.gui import qt +from silx.gui.qt import inspect as _inspect + + +if qt.BINDING == 'PySide2': + from PySide2.QtTest import QTest +elif qt.BINDING == 'PyQt5': + from PyQt5.QtTest import QTest +elif qt.BINDING == 'PySide6': + from PySide6.QtTest import QTest +else: + raise ImportError('Unsupported Qt bindings') + + +def qWaitForWindowExposedAndActivate(window, timeout=None): + """Waits until the window is shown in the screen. + + It also activates the window and raises it. + + See QTest.qWaitForWindowExposed for details. + """ + if timeout is None: + result = QTest.qWaitForWindowExposed(window) + else: + result = QTest.qWaitForWindowExposed(window, timeout) + + if result: + # Makes sure window is active and on top + window.activateWindow() + window.raise_() + + return result + + +class TestCaseQt(unittest.TestCase): + """Base class to write test for Qt stuff. + + It creates a QApplication before running the tests. + WARNING: The QApplication is shared by all tests, which might have side + effects. + + After each test, this class is checking for widgets remaining alive. + To allow some widgets to remain alive at the end of a test, set the + allowedLeakingWidgets attribute to the number of widgets that can remain + alive at the end of the test. + With PySide2, this test is not run for now as it seems PySide2 + is leaking widgets internally. + + All keyboard and mouse event simulation methods call qWait(20) after + simulating the event (as QTest does on Mac OSX). + This was introduced to fix issues with continuous integration tests + running with Xvfb on Linux. + """ + + DEFAULT_TIMEOUT_WAIT = 100 + """Default timeout for qWait""" + + TIMEOUT_WAIT = 0 + """Extra timeout in millisecond to add to qSleep, qWait and + qWaitForWindowExposed. + + Intended purpose is for debugging, to add extra time to waits in order to + allow to view the tested widgets. + """ + + _qapp = None + """Placeholder for QApplication""" + + @classmethod + def exceptionHandler(cls, exceptionClass, exception, stack): + import traceback + message = (''.join(traceback.format_tb(stack))) + template = 'Traceback (most recent call last):\n{2}{0}: {1}' + message = template.format(exceptionClass.__name__, exception, message) + cls._exceptions.append(message) + + @classmethod + def setUpClass(cls): + """Makes sure Qt is inited""" + cls._oldExceptionHook = sys.excepthook + sys.excepthook = cls.exceptionHandler + + # Makes sure a QApplication exists and do it once for all + if not qt.QApplication.instance(): + cls._qapp = qt.QApplication([]) + + @classmethod + def tearDownClass(cls): + sys.excepthook = cls._oldExceptionHook + + def setUp(self): + """Get the list of existing widgets.""" + self.allowedLeakingWidgets = 0 + if qt.BINDING in ('PySide2', 'PySide6'): + self.__previousWidgets = None + else: + self.__previousWidgets = self.qapp.allWidgets() + self.__class__._exceptions = [] + + def _currentTestSucceeded(self): + if hasattr(self, '_outcome'): + # For Python >= 3.4 + result = self.defaultTestResult() # these 2 methods have no side effects + if hasattr(self._outcome, 'errors'): + self._feedErrorsToResult(result, self._outcome.errors) + else: + # For Python < 3.4 + result = getattr(self, '_outcomeForDoCleanups', self._resultForDoCleanups) + + skipped = self.id() in [case.id() for case, _ in result.skipped] + error = self.id() in [case.id() for case, _ in result.errors] + failure = self.id() in [case.id() for case, _ in result.failures] + return not error and not failure and not skipped + + def _checkForUnreleasedWidgets(self): + """Test fixture checking that no more widgets exists.""" + gc.collect() + + if self.__previousWidgets is None: + return # Do not test for leaking widgets with PySide2 + + widgets = [widget for widget in self.qapp.allWidgets() + if (widget not in self.__previousWidgets and + _inspect.createdByPython(widget))] + self.__previousWidgets = None + + allowedLeakingWidgets = self.allowedLeakingWidgets + self.allowedLeakingWidgets = 0 + + if widgets and len(widgets) <= allowedLeakingWidgets: + _logger.info( + '%s: %d remaining widgets after test' % (self.id(), + len(widgets))) + + if len(widgets) > allowedLeakingWidgets: + raise RuntimeError( + "Test ended with widgets alive: %s" % str(widgets)) + + def tearDown(self): + self.qapp.processEvents() + + if len(self.__class__._exceptions) > 0: + messages = "\n".join(self.__class__._exceptions) + raise AssertionError("Exception occured in Qt thread:\n" + messages) + + if self._currentTestSucceeded(): + self._checkForUnreleasedWidgets() + + @property + def qapp(self): + """The QApplication currently running.""" + return qt.QApplication.instance() + + # Proxy to QTest + + Press = QTest.Press + """Key press action code""" + + Release = QTest.Release + """Key release action code""" + + Click = QTest.Click + """Key click action code""" + + QTest = property(lambda self: QTest, + doc="""The Qt QTest class from the used Qt binding.""") + + def keyClick(self, widget, key, modifier=qt.Qt.NoModifier, delay=-1): + """Simulate clicking a key. + + See QTest.keyClick for details. + """ + QTest.keyClick(widget, key, modifier, delay) + self.qWait(20) + + def keyClicks(self, widget, sequence, modifier=qt.Qt.NoModifier, delay=-1): + """Simulate clicking a sequence of keys. + + See QTest.keyClick for details. + """ + QTest.keyClicks(widget, sequence, modifier, delay) + self.qWait(20) + + def keyEvent(self, action, widget, key, + modifier=qt.Qt.NoModifier, delay=-1): + """Sends a Qt key event. + + See QTest.keyEvent for details. + """ + QTest.keyEvent(action, widget, key, modifier, delay) + self.qWait(20) + + def keyPress(self, widget, key, modifier=qt.Qt.NoModifier, delay=-1): + """Sends a Qt key press event. + + See QTest.keyPress for details. + """ + QTest.keyPress(widget, key, modifier, delay) + self.qWait(20) + + def keyRelease(self, widget, key, modifier=qt.Qt.NoModifier, delay=-1): + """Sends a Qt key release event. + + See QTest.keyRelease for details. + """ + QTest.keyRelease(widget, key, modifier, delay) + self.qWait(20) + + def mouseClick(self, widget, button, modifier=None, pos=None, delay=-1): + """Simulate clicking a mouse button. + + See QTest.mouseClick for details. + """ + if modifier is None: + modifier = qt.Qt.KeyboardModifiers() + pos = qt.QPoint(int(pos[0]), int(pos[1])) if pos is not None else qt.QPoint() + QTest.mouseClick(widget, button, modifier, pos, delay) + self.qWait(20) + + def mouseDClick(self, widget, button, modifier=None, pos=None, delay=-1): + """Simulate double clicking a mouse button. + + See QTest.mouseDClick for details. + """ + if modifier is None: + modifier = qt.Qt.KeyboardModifiers() + pos = qt.QPoint(int(pos[0]), int(pos[1])) if pos is not None else qt.QPoint() + QTest.mouseDClick(widget, button, modifier, pos, delay) + self.qWait(20) + + def mouseMove(self, widget, pos=None, delay=-1): + """Simulate moving the mouse. + + See QTest.mouseMove for details. + """ + pos = qt.QPoint(int(pos[0]), int(pos[1])) if pos is not None else qt.QPoint() + QTest.mouseMove(widget, pos, delay) + self.qWait(20) + + def mousePress(self, widget, button, modifier=None, pos=None, delay=-1): + """Simulate pressing a mouse button. + + See QTest.mousePress for details. + """ + if modifier is None: + modifier = qt.Qt.KeyboardModifiers() + pos = qt.QPoint(int(pos[0]), int(pos[1])) if pos is not None else qt.QPoint() + QTest.mousePress(widget, button, modifier, pos, delay) + self.qWait(20) + + def mouseRelease(self, widget, button, modifier=None, pos=None, delay=-1): + """Simulate releasing a mouse button. + + See QTest.mouseRelease for details. + """ + if modifier is None: + modifier = qt.Qt.KeyboardModifiers() + pos = qt.QPoint(int(pos[0]), int(pos[1])) if pos is not None else qt.QPoint() + QTest.mouseRelease(widget, button, modifier, pos, delay) + self.qWait(20) + + def qSleep(self, ms): + """Sleep for ms milliseconds, blocking the execution of the test. + + See QTest.qSleep for details. + """ + QTest.qSleep(int(ms) + self.TIMEOUT_WAIT) + + @classmethod + def qWait(cls, ms=None): + """Waits for ms milliseconds, events will be processed. + + See QTest.qWait for details. + """ + if ms is None: + ms = cls.DEFAULT_TIMEOUT_WAIT + + if qt.BINDING in ('PySide2', 'PySide6'): + # PySide2 has no qWait, provide a replacement + timeout = int(ms) + endTimeMS = int(time.time() * 1000) + timeout + qapp = qt.QApplication.instance() + while timeout > 0: + qapp.processEvents(qt.QEventLoop.AllEvents, + timeout) + timeout = endTimeMS - int(time.time() * 1000) + else: + QTest.qWait(int(ms) + cls.TIMEOUT_WAIT) + + def qWaitForWindowExposed(self, window, timeout=None): + """Waits until the window is shown in the screen. + + See QTest.qWaitForWindowExposed for details. + """ + result = qWaitForWindowExposedAndActivate(window, timeout) + + if self.TIMEOUT_WAIT: + QTest.qWait(self.TIMEOUT_WAIT) + + return result + + def exposeAndClose(self, widget): + """Wait for expose a widget, flag it delete on close, and close it.""" + self.qWaitForWindowExposed(widget) + self.qapp.processEvents() + widget.setAttribute(qt.Qt.WA_DeleteOnClose) + widget.close() + + _qobject_destroyed = False + + @classmethod + def _aboutToDestroy(cls): + cls._qobject_destroyed = True + + @classmethod + def qWaitForDestroy(cls, ref): + """ + Wait for Qt object destruction. + + Use a weakref as parameter to avoid any strong references to the + object. + + It have to be used as following. Removing the reference to the object + before calling the function looks to be expected, else + :meth:`deleteLater` will not work. + + .. code-block:: python + + ref = weakref.ref(self.obj) + self.obj = None + self.qWaitForDestroy(ref) + + :param weakref ref: A weakref to an object to avoid any reference + :return: True if the object was destroyed + :rtype: bool + """ + cls._qobject_destroyed = False + qobject = ref() + if qobject is None: + return True + qobject.destroyed.connect(cls._aboutToDestroy) + qobject.deleteLater() + qobject = None + for _ in range(10): + if cls._qobject_destroyed: + break + cls.qWait(10) + else: + _logger.debug("Object was not destroyed") + + return ref() is None + + def logScreenShot(self, level=logging.ERROR): + """Take a screenshot and log it into the logging system if the + logger is enabled for the expected level. + + The screenshot is stored in the directory "./build/test-debug", and + the logging system only log the path to this file. + + :param level: Logging level + """ + if not _logger.isEnabledFor(level): + return + basedir = os.path.abspath(os.path.join("build", "test-debug")) + if not os.path.exists(basedir): + os.makedirs(basedir) + filename = "Screenshot_%s.png" % self.id() + filename = os.path.join(basedir, filename) + + screen = self.qapp.primaryScreen() + pixmap = screen.grabWindow(0) + pixmap.save(filename) + _logger.log(level, "Screenshot saved at %s", filename) + + +class SignalListener(object): + """Util to listen a Qt event and store parameters + """ + + def __init__(self): + self.__calls = [] + + def __call__(self, *args, **kargs): + self.__calls.append((args, kargs)) + + def clear(self): + """Clear stored data""" + self.__calls = [] + + def callCount(self): + """ + Returns how many times the listener was called. + + :rtype: int + """ + return len(self.__calls) + + def arguments(self, callIndex=None, argumentIndex=None): + """Returns positional arguments optionally filtered by call count id + or argument index. + + :param int callIndex: Index of the called data + :param int argumentIndex: Index of the positional argument. + """ + if callIndex is not None: + result = self.__calls[callIndex][0] + if argumentIndex is not None: + result = result[argumentIndex] + else: + result = [x[0] for x in self.__calls] + if argumentIndex is not None: + result = [x[argumentIndex] for x in result] + return result + + def karguments(self, callIndex=None, argumentName=None): + """Returns positional arguments optionally filtered by call count id + or name of the keyword argument. + + :param int callIndex: Index of the called data + :param int argumentName: Name of the keyword argument. + """ + if callIndex is not None: + result = self.__calls[callIndex][1] + if argumentName is not None: + result = result[argumentName] + else: + result = [x[1] for x in self.__calls] + if argumentName is not None: + result = [x[argumentName] for x in result] + return result + + def partial(self, *args, **kargs): + """Returns a new partial object which when called will behave like this + listener called with the positional arguments args and keyword + arguments keywords. If more arguments are supplied to the call, they + are appended to args. If additional keyword arguments are supplied, + they extend and override keywords. + """ + return functools.partial(self, *args, **kargs) + + +def getQToolButtonFromAction(action): + """Return a QToolButton corresponding to a QAction. + + :param QAction action: The QAction from which to get QToolButton. + :return: A QToolButton associated to action or None. + """ + if qt.BINDING == "PySide6": + widgets = action.associatedObjects() + else: + widgets = action.associatedWidgets() + + for widget in widgets: + if isinstance(widget, qt.QToolButton): + return widget + return None + + +def findChildren(parent, kind, name=None): + if qt.BINDING in ("PySide2", "PySide6") and name is not None: + result = [] + for obj in parent.findChildren(kind): + if obj.objectName() == name: + result.append(obj) + return result + else: + return parent.findChildren(kind, name=name) |