diff --git a/artiq/frontend/artiq_gui.py b/artiq/frontend/artiq_gui.py index 97a4cce6e..93fd4248e 100755 --- a/artiq/frontend/artiq_gui.py +++ b/artiq/frontend/artiq_gui.py @@ -91,11 +91,12 @@ def main(): rpc_clients[target] = client sub_clients = dict() - for notifier_name, module in (("explist", explorer), - ("datasets", datasets), - ("schedule", schedule), - ("log", log)): - subscriber = ModelSubscriber(notifier_name, module.Model) + for notifier_name, modelf in (("explist", explorer.Model), + ("explist_status", explorer.StatusUpdater), + ("datasets", datasets.Model), + ("schedule", schedule.Model), + ("log", log.Model)): + subscriber = ModelSubscriber(notifier_name, modelf) loop.run_until_complete(subscriber.connect( args.server, args.port_notify)) atexit_register_coroutine(subscriber.close) @@ -123,6 +124,7 @@ def main(): smgr.register(d_shortcuts) d_explorer = explorer.ExplorerDock(status_bar, expmgr, d_shortcuts, sub_clients["explist"], + sub_clients["explist_status"], rpc_clients["schedule"], rpc_clients["experiment_db"]) diff --git a/artiq/gui/explorer.py b/artiq/gui/explorer.py index b1c5fb756..760ad5a3d 100644 --- a/artiq/gui/explorer.py +++ b/artiq/gui/explorer.py @@ -7,6 +7,7 @@ from PyQt5 import QtCore, QtWidgets from artiq.gui.tools import LayoutWidget from artiq.gui.models import DictSyncTreeSepModel +from artiq.gui.waitingspinnerwidget import QtWaitingSpinner logger = logging.getLogger(__name__) @@ -117,38 +118,81 @@ class Model(DictSyncTreeSepModel): DictSyncTreeSepModel.__init__(self, "/", ["Experiment"], init) +class StatusUpdater: + def __init__(self, init): + self.status = init + self.explorer = None + + def set_explorer(self, explorer): + self.explorer = explorer + self.explorer.update_scanning(self.status["scanning"]) + self.explorer.update_cur_rev(self.status["cur_rev"]) + + def __setitem__(self, k, v): + self.status[k] = v + if self.explorer is not None: + if k == "scanning": + self.explorer.update_scanning(v) + elif k == "cur_rev": + self.explorer.update_cur_rev(v) + + +class WaitingPanel(LayoutWidget): + def __init__(self): + LayoutWidget.__init__(self) + + self.waiting_spinner = QtWaitingSpinner() + self.addWidget(self.waiting_spinner, 1, 1) + self.addWidget(QtWidgets.QLabel("Repository scan in progress..."), 1, 2) + + def start(self): + self.waiting_spinner.start() + + def stop(self): + self.waiting_spinner.stop() + + class ExplorerDock(QtWidgets.QDockWidget): def __init__(self, status_bar, exp_manager, d_shortcuts, - explist_sub, schedule_ctl, experiment_db_ctl): + explist_sub, explist_status_sub, + schedule_ctl, experiment_db_ctl): QtWidgets.QDockWidget.__init__(self, "Explorer") self.setObjectName("Explorer") self.setFeatures(QtWidgets.QDockWidget.DockWidgetMovable | QtWidgets.QDockWidget.DockWidgetFloatable) - layout = QtWidgets.QGridLayout() - top_widget = QtWidgets.QWidget() - top_widget.setLayout(layout) + top_widget = LayoutWidget() self.setWidget(top_widget) - layout.setSpacing(5) - layout.setContentsMargins(5, 5, 5, 5) self.status_bar = status_bar self.exp_manager = exp_manager self.d_shortcuts = d_shortcuts self.schedule_ctl = schedule_ctl + top_widget.addWidget(QtWidgets.QLabel("Revision:"), 0, 0) + self.revision = QtWidgets.QLabel() + self.revision.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse) + top_widget.addWidget(self.revision, 0, 1) + + self.stack = QtWidgets.QStackedWidget() + top_widget.addWidget(self.stack, 1, 0, colspan=2) + + self.el_buttons = LayoutWidget() + self.el_buttons.layout.setContentsMargins(0, 0, 0, 0) + self.stack.addWidget(self.el_buttons) + self.el = QtWidgets.QTreeView() self.el.setHeaderHidden(True) self.el.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectItems) - layout.addWidget(self.el, 0, 0, 1, 2) self.el.doubleClicked.connect( partial(self.expname_action, "open_experiment")) + self.el_buttons.addWidget(self.el, 0, 0, colspan=2) open = QtWidgets.QPushButton("Open") open.setIcon(QtWidgets.QApplication.style().standardIcon( QtWidgets.QStyle.SP_DialogOpenButton)) open.setToolTip("Open the selected experiment (Return)") - layout.addWidget(open, 1, 0) + self.el_buttons.addWidget(open, 1, 0) open.clicked.connect( partial(self.expname_action, "open_experiment")) @@ -156,7 +200,7 @@ class ExplorerDock(QtWidgets.QDockWidget): submit.setIcon(QtWidgets.QApplication.style().standardIcon( QtWidgets.QStyle.SP_DialogOkButton)) submit.setToolTip("Schedule the selected experiment (Ctrl+Return)") - layout.addWidget(submit, 1, 1) + self.el_buttons.addWidget(submit, 1, 1) submit.clicked.connect( partial(self.expname_action, "submit")) @@ -201,7 +245,6 @@ class ExplorerDock(QtWidgets.QDockWidget): self.el) def scan_repository(): asyncio.ensure_future(experiment_db_ctl.scan_repository_async()) - self.status_bar.showMessage("Requested repository scan") scan_repository_action.triggered.connect(scan_repository) self.el.addAction(scan_repository_action) @@ -213,6 +256,11 @@ class ExplorerDock(QtWidgets.QDockWidget): experiment_db_ctl).open()) self.el.addAction(open_file_action) + self.waiting_panel = WaitingPanel() + self.stack.addWidget(self.waiting_panel) + explist_status_sub.add_setmodel_callback( + lambda updater: updater.set_explorer(self)) + def set_model(self, model): self.explist_model = model self.el.setModel(model) @@ -237,3 +285,14 @@ class ExplorerDock(QtWidgets.QDockWidget): self.d_shortcuts.set_shortcut(nr, expurl) self.status_bar.showMessage("Set shortcut F{} to '{}'" .format(nr+1, expurl)) + + def update_scanning(self, scanning): + if scanning: + self.stack.setCurrentWidget(self.waiting_panel) + self.waiting_panel.start() + else: + self.stack.setCurrentWidget(self.el_buttons) + self.waiting_panel.stop() + + def update_cur_rev(self, cur_rev): + self.revision.setText(cur_rev) diff --git a/artiq/gui/waitingspinnerwidget.py b/artiq/gui/waitingspinnerwidget.py new file mode 100644 index 000000000..6771bdbfe --- /dev/null +++ b/artiq/gui/waitingspinnerwidget.py @@ -0,0 +1,186 @@ +""" +The MIT License (MIT) + +Copyright (c) 2012-2014 Alexander Turkin +Copyright (c) 2014 William Hallatt +Copyright (c) 2015 Jacob Dawid +Copyright (c) 2016 Luca Weiss + +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. +""" + +import math + +from PyQt5.QtCore import * +from PyQt5.QtGui import * +from PyQt5.QtWidgets import * + + +class QtWaitingSpinner(QWidget): + def __init__(self): + super().__init__() + + # WAS IN initialize() + self._color = QColor(Qt.black) + self._roundness = 100.0 + self._minimumTrailOpacity = 3.14159265358979323846 + self._trailFadePercentage = 80.0 + self._revolutionsPerSecond = 1.57079632679489661923 + self._numberOfLines = 20 + self._lineLength = 10 + self._lineWidth = 2 + self._innerRadius = 10 + self._currentCounter = 0 + + self._timer = QTimer(self) + self._timer.timeout.connect(self.rotate) + self.updateSize() + self.updateTimer() + # END initialize() + + self.setAttribute(Qt.WA_TranslucentBackground) + + def paintEvent(self, QPaintEvent): + painter = QPainter(self) + painter.fillRect(self.rect(), Qt.transparent) + painter.setRenderHint(QPainter.Antialiasing, True) + + if self._currentCounter >= self._numberOfLines: + self._currentCounter = 0 + + painter.setPen(Qt.NoPen) + for i in range(0, self._numberOfLines): + painter.save() + painter.translate(self._innerRadius + self._lineLength, self._innerRadius + self._lineLength) + rotateAngle = float(360 * i) / float(self._numberOfLines) + painter.rotate(rotateAngle) + painter.translate(self._innerRadius, 0) + distance = self.lineCountDistanceFromPrimary(i, self._currentCounter, self._numberOfLines) + color = self.currentLineColor(distance, self._numberOfLines, self._trailFadePercentage, + self._minimumTrailOpacity, self._color) + painter.setBrush(color) + painter.drawRoundedRect(QRect(0, -self._lineWidth / 2, self._lineLength, self._lineWidth), self._roundness, + self._roundness, Qt.RelativeSize) + painter.restore() + + def start(self): + if not self._timer.isActive(): + self._timer.start() + self._currentCounter = 0 + + def stop(self): + if self._timer.isActive(): + self._timer.stop() + self._currentCounter = 0 + + def setNumberOfLines(self, lines): + self._numberOfLines = lines + self._currentCounter = 0 + self.updateTimer() + + def setLineLength(self, length): + self._lineLength = length + self.updateSize() + + def setLineWidth(self, width): + self._lineWidth = width + self.updateSize() + + def setInnerRadius(self, radius): + self._innerRadius = radius + self.updateSize() + + def color(self): + return self._color + + def roundness(self): + return self._roundness + + def minimumTrailOpacity(self): + return self._minimumTrailOpacity + + def trailFadePercentage(self): + return self._trailFadePercentage + + def revolutionsPersSecond(self): + return self._revolutionsPerSecond + + def numberOfLines(self): + return self._numberOfLines + + def lineLength(self): + return self._lineLength + + def lineWidth(self): + return self._lineWidth + + def innerRadius(self): + return self._innerRadius + + def setRoundness(self, roundness): + self._roundness = max(0.0, min(100.0, roundness)) + + def setColor(self, color=Qt.black): + self._color = QColor(color) + + def setRevolutionsPerSecond(self, revolutionsPerSecond): + self._revolutionsPerSecond = revolutionsPerSecond + self.updateTimer() + + def setTrailFadePercentage(self, trail): + self._trailFadePercentage = trail + + def setMinimumTrailOpacity(self, minimumTrailOpacity): + self._minimumTrailOpacity = minimumTrailOpacity + + def rotate(self): + self._currentCounter += 1 + if self._currentCounter >= self._numberOfLines: + self._currentCounter = 0 + self.update() + + def updateSize(self): + self.size = (self._innerRadius + self._lineLength) * 2 + self.setFixedSize(self.size, self.size) + + def updateTimer(self): + self._timer.setInterval(1000 / (self._numberOfLines * self._revolutionsPerSecond)) + + def lineCountDistanceFromPrimary(self, current, primary, totalNrOfLines): + distance = primary - current + if distance < 0: + distance += totalNrOfLines + return distance + + def currentLineColor(self, countDistance, totalNrOfLines, trailFadePerc, minOpacity, colorinput): + color = QColor(colorinput) + if countDistance == 0: + return color + minAlphaF = minOpacity / 100.0 + distanceThreshold = int(math.ceil((totalNrOfLines - 1) * trailFadePerc / 100.0)) + if countDistance > distanceThreshold: + color.setAlphaF(minAlphaF) + else: + alphaDiff = color.alphaF() - minAlphaF + gradient = alphaDiff / float(distanceThreshold + 1) + resultAlpha = color.alphaF() - gradient * countDistance + # If alpha is out of bounds, clip it. + resultAlpha = min(1.0, max(0.0, resultAlpha)) + color.setAlphaF(resultAlpha) + return color