summaryrefslogtreecommitdiff
path: root/src/silx/gui/utils
diff options
context:
space:
mode:
Diffstat (limited to 'src/silx/gui/utils')
-rwxr-xr-xsrc/silx/gui/utils/__init__.py76
-rw-r--r--src/silx/gui/utils/concurrent.py105
-rw-r--r--src/silx/gui/utils/glutils/__init__.py199
-rw-r--r--src/silx/gui/utils/image.py143
-rw-r--r--src/silx/gui/utils/matplotlib.py65
-rw-r--r--src/silx/gui/utils/projecturl.py77
-rwxr-xr-xsrc/silx/gui/utils/qtutils.py196
-rw-r--r--src/silx/gui/utils/signal.py141
-rwxr-xr-xsrc/silx/gui/utils/test/__init__.py25
-rw-r--r--src/silx/gui/utils/test/test.py63
-rw-r--r--src/silx/gui/utils/test/test_async.py127
-rw-r--r--src/silx/gui/utils/test/test_glutils.py55
-rw-r--r--src/silx/gui/utils/test/test_image.py79
-rwxr-xr-xsrc/silx/gui/utils/test/test_qtutils.py65
-rw-r--r--src/silx/gui/utils/test/test_testutils.py44
-rw-r--r--src/silx/gui/utils/testutils.py508
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)