diff --git a/examples/waiterOverlay.py b/examples/waiterOverlay.py new file mode 100644 index 0000000000..b087bf94b4 --- /dev/null +++ b/examples/waiterOverlay.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python +# /*########################################################################## +# +# Copyright (c) 2023 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. +# +# ###########################################################################*/ +""" +Demonstration window that displays a wait icon until the plot is updated +""" + +import numpy.random +from silx.gui import qt +from silx.gui.widgets.WaitingOverlay import WaitingOverlay +from silx.gui.plot import Plot2D + + +class MyMainWindow(qt.QMainWindow): + + WAITING_TIME = 2000 # ms + + def __init__(self, parent=None): + super().__init__(parent) + + # central plot + self._plot = Plot2D() + self._waitingOverlay = WaitingOverlay(self._plot) + self.setCentralWidget(self._plot) + + # button to trigger image generation + self._rightPanel = qt.QWidget(self) + self._rightPanel.setLayout(qt.QVBoxLayout()) + self._button = qt.QPushButton("generate image", self) + self._rightPanel.layout().addWidget(self._button) + + self._dockWidget = qt.QDockWidget() + self._dockWidget.setWidget(self._rightPanel) + self.addDockWidget(qt.Qt.RightDockWidgetArea, self._dockWidget) + + # set up + self._waitingOverlay.hide() + self._waitingOverlay.setIconSize(qt.QSize(60, 60)) + # connect signal / slot + self._button.clicked.connect(self._triggerImageCalculation) + + def _generateRandomData(self): + self.setData(numpy.random.random(1000 * 500).reshape((1000, 500))) + self._button.setEnabled(True) + + def setData(self, data): + self._plot.addImage(data) + self._waitingOverlay.hide() + + def _triggerImageCalculation(self): + self._plot.clear() + self._button.setEnabled(False) + self._waitingOverlay.show() + qt.QTimer.singleShot(self.WAITING_TIME, self._generateRandomData) + + +qapp = qt.QApplication([]) +window = MyMainWindow() +window.show() +qapp.exec_() diff --git a/src/silx/gui/plot/ImageStack.py b/src/silx/gui/plot/ImageStack.py index 5674076e09..d64991b255 100644 --- a/src/silx/gui/plot/ImageStack.py +++ b/src/silx/gui/plot/ImageStack.py @@ -28,106 +28,18 @@ __date__ = "04/03/2019" -from silx.gui import icons, qt +from silx.gui import qt from silx.gui.plot import Plot2D -from silx.gui.utils import concurrent from silx.io.url import DataUrl from silx.io.utils import get_data from silx.gui.widgets.FrameBrowser import HorizontalSliderWithBrowser -import time -import threading import typing import logging +from silx.gui.widgets.WaitingOverlay import WaitingOverlay _logger = logging.getLogger(__name__) -class _PlotWithWaitingLabel(qt.QWidget): - """Image plot widget with an overlay 'waiting' status. - """ - - class AnimationThread(threading.Thread): - def __init__(self, label): - self.running = True - self._label = label - self.animated_icon = icons.getWaitIcon() - self.animated_icon.register(self._label) - super(_PlotWithWaitingLabel.AnimationThread, self).__init__() - - def run(self): - while self.running: - time.sleep(0.05) - icon = self.animated_icon.currentIcon() - self.future_result = concurrent.submitToQtMainThread( - self._label.setPixmap, icon.pixmap(30, state=qt.QIcon.On)) - - def stop(self): - """Stop the update thread""" - if self.running: - self.animated_icon.unregister(self._label) - self.running = False - self.join(2) - - def __init__(self, parent): - super(_PlotWithWaitingLabel, self).__init__(parent=parent) - self._autoResetZoom = True - layout = qt.QStackedLayout(self) - layout.setStackingMode(qt.QStackedLayout.StackAll) - - self._waiting_label = qt.QLabel(parent=self) - self._waiting_label.setAlignment(qt.Qt.AlignHCenter | qt.Qt.AlignVCenter) - layout.addWidget(self._waiting_label) - - self._plot = Plot2D(parent=self) - layout.addWidget(self._plot) - - self.updateThread = _PlotWithWaitingLabel.AnimationThread(self._waiting_label) - self.updateThread.start() - - def close(self) -> bool: - super(_PlotWithWaitingLabel, self).close() - self.stopUpdateThread() - - def stopUpdateThread(self): - self.updateThread.stop() - - def setAutoResetZoom(self, reset): - """ - Should we reset the zoom when adding an image (eq. when browsing) - - :param bool reset: - """ - self._autoResetZoom = reset - if self._autoResetZoom: - self._plot.resetZoom() - - def isAutoResetZoom(self): - """ - - :return: True if a reset is done when the image change - :rtype: bool - """ - return self._autoResetZoom - - def setWaiting(self, activate=True): - if activate is True: - self._plot.clear() - self._waiting_label.show() - else: - self._waiting_label.hide() - - def setData(self, data): - self.setWaiting(activate=False) - self._plot.addImage(data=data, resetzoom=self._autoResetZoom) - - def clear(self): - self._plot.clear() - self.setWaiting(False) - - def getPlotWidget(self): - return self._plot - - class _HorizontalSlider(HorizontalSliderWithBrowser): sigCurrentUrlIndexChanged = qt.Signal(int) @@ -283,10 +195,13 @@ def __init__(self, parent=None) -> None: self._current_url = None self._url_loader = UrlLoader "class to instantiate for loading urls" + self._autoResetZoom = True # main widget - self._plot = _PlotWithWaitingLabel(parent=self) + self._plot = Plot2D(parent=self) self._plot.setAttribute(qt.Qt.WA_DeleteOnClose, True) + self._waitingOverlay = WaitingOverlay(self._plot) + self._waitingOverlay.setIconSize(qt.QSize(30, 30)) self.setWindowTitle("Image stack") self.setCentralWidget(self._plot) @@ -311,6 +226,7 @@ def __init__(self, parent=None) -> None: def close(self) -> bool: self._freeLoadingThreads() + self._waitingOverlay.close() self._plot.close() super(ImageStack, self).close() @@ -345,7 +261,7 @@ def getPlotWidget(self) -> Plot2D: :return: PlotWidget contained in this window :rtype: Plot2D """ - return self._plot.getPlotWidget() + return self._plot def reset(self) -> None: """Clear the plot and remove any link to url""" @@ -395,7 +311,8 @@ def _urlLoaded(self) -> None: if url in self._urlIndexes: self._urlData[url] = sender.data if self.getCurrentUrl().path() == url: - self._plot.setData(self._urlData[url]) + self._waitingOverlay.setVisible(False) + self._plot.addImage(self._urlData[url], resetzoom=self._autoResetZoom) if sender in self._loadingThreads: self._loadingThreads.remove(sender) self.sigLoaded.emit(url) @@ -581,10 +498,12 @@ def setCurrentUrl(self, url: typing.Union[DataUrl, str]) -> None: self._plot.clear() else: if self._current_url.path() in self._urlData: - self._plot.setData(self._urlData[url.path()]) + self._waitingOverlay.setVisible(False) + self._plot.addImage(self._urlData[url.path()], resetzoom=self._autoResetZoom) else: + self._plot.clear() self._load(url) - self._notifyLoading() + self._waitingOverlay.setVisible(True) self._preFetch(self._getNNextUrls(self.__n_prefetch, url)) self._preFetch(self._getNPreviousUrls(self.__n_prefetch, url)) self._urlsTable.blockSignals(old_url_table) @@ -617,17 +536,15 @@ def _urlsToIndex(urls): res[url.path()] = index return res - def _notifyLoading(self): - """display a simple image of loading...""" - self._plot.setWaiting(activate=True) - def setAutoResetZoom(self, reset): """ Should we reset the zoom when adding an image (eq. when browsing) :param bool reset: """ - self._plot.setAutoResetZoom(reset) + self._autoResetZoom = reset + if self._autoResetZoom: + self._plot.resetZoom() def isAutoResetZoom(self) -> bool: """ @@ -635,4 +552,4 @@ def isAutoResetZoom(self) -> bool: :return: True if a reset is done when the image change :rtype: bool """ - return self._plot.isAutoResetZoom() + return self._autoResetZoom diff --git a/src/silx/gui/widgets/WaitingOverlay.py b/src/silx/gui/widgets/WaitingOverlay.py new file mode 100644 index 0000000000..19eba137ee --- /dev/null +++ b/src/silx/gui/widgets/WaitingOverlay.py @@ -0,0 +1,109 @@ +import weakref +from typing import Optional +from silx.gui.widgets.WaitingPushButton import WaitingPushButton +from silx.gui import qt +from silx.gui.qt import inspect as qt_inspect +from silx.gui.plot import PlotWidget + + +class WaitingOverlay(qt.QWidget): + """Widget overlaying another widget with a processing wheel icon. + + :param parent: widget on top of which to display the "processing/waiting wheel" + """ + + def __init__(self, parent: qt.QWidget) -> None: + super().__init__(parent) + self.setContentsMargins(0, 0, 0, 0) + + self._waitingButton = WaitingPushButton(self) + self._waitingButton.setDown(True) + self._waitingButton.setWaiting(True) + self._waitingButton.setStyleSheet("QPushButton { background-color: rgba(150, 150, 150, 40); border: 0px; border-radius: 10px; }") + self._registerParent(parent) + + def text(self) -> str: + """Returns displayed text""" + return self._waitingButton.text() + + def setText(self, text: str): + """Set displayed text""" + self._waitingButton.setText(text) + self._resize() + + def _listenedWidget(self, parent: qt.QWidget) -> qt.QWidget: + """Returns widget to register event filter to according to parent""" + if isinstance(parent, PlotWidget): + return parent.getWidgetHandle() + return parent + + def _backendChanged(self): + self._listenedWidget(self.parent()).installEventFilter(self) + self._resizeLater() + + def _registerParent(self, parent: Optional[qt.QWidget]): + if parent is None: + return + self._listenedWidget(parent).installEventFilter(self) + if isinstance(parent, PlotWidget): + parent.sigBackendChanged.connect(self._backendChanged) + self._resize() + + def _unregisterParent(self, parent: Optional[qt.QWidget]): + if parent is None: + return + if isinstance(parent, PlotWidget): + parent.sigBackendChanged.disconnect(self._backendChanged) + self._listenedWidget(parent).removeEventFilter(self) + + def setParent(self, parent: qt.QWidget): + self._unregisterParent(self.parent()) + super().setParent(parent) + self._registerParent(parent) + + def showEvent(self, event: qt.QShowEvent): + super().showEvent(event) + self._waitingButton.setVisible(True) + + def hideEvent(self, event: qt.QHideEvent): + super().hideEvent(event) + self._waitingButton.setVisible(False) + + def _resize(self): + if not qt_inspect.isValid(self): + return # For _resizeLater in case the widget has been deleted + + parent = self.parent() + if parent is None: + return + + size = self._waitingButton.sizeHint() + if isinstance(parent, PlotWidget): + offset = parent.getWidgetHandle().mapTo(parent, qt.QPoint(0, 0)) + left, top, width, height = parent.getPlotBoundsInPixels() + rect = qt.QRect( + qt.QPoint( + int(offset.x() + left + width / 2 - size.width() / 2), + int(offset.y() + top + height / 2 - size.height() / 2), + ), + size, + ) + else: + position = parent.size() + position = (position - size) / 2 + rect = qt.QRect(qt.QPoint(position.width(), position.height()), size) + self.setGeometry(rect) + self.raise_() + + def _resizeLater(self): + qt.QTimer.singleShot(0, self._resize) + + def eventFilter(self, watched: qt.QWidget, event: qt.QEvent): + if event.type() == qt.QEvent.Resize: + self._resize() + self._resizeLater() # Defer resize for the receiver to have handled it + return super().eventFilter(watched, event) + + # expose Waiting push button API + def setIconSize(self, size): + self._waitingButton.setIconSize(size) diff --git a/src/silx/gui/widgets/test/test_waitingoverlay.py b/src/silx/gui/widgets/test/test_waitingoverlay.py new file mode 100644 index 0000000000..713c4cbafa --- /dev/null +++ b/src/silx/gui/widgets/test/test_waitingoverlay.py @@ -0,0 +1,31 @@ +import pytest +from silx.gui import qt +from silx.gui.widgets.WaitingOverlay import WaitingOverlay +from silx.gui.plot import Plot2D +from silx.gui.plot.PlotWidget import PlotWidget + + +@pytest.mark.parametrize("widget_parent", (Plot2D, qt.QFrame)) +def test_show(qapp, qapp_utils, widget_parent): + """Simple test of the WaitingOverlay component""" + widget = widget_parent() + widget.setAttribute(qt.Qt.WA_DeleteOnClose) + + waitingOverlay = WaitingOverlay(widget) + waitingOverlay.setAttribute(qt.Qt.WA_DeleteOnClose) + + widget.show() + qapp_utils.qWaitForWindowExposed(widget) + assert waitingOverlay._waitingButton.isWaiting() + + waitingOverlay.setText("test") + qapp.processEvents() + assert waitingOverlay.text() == "test" + qapp_utils.qWait(1000) + + waitingOverlay.hide() + qapp.processEvents() + + widget.close() + waitingOverlay.close() + qapp.processEvents()