diff --git a/artiq/dashboard/experiment_tabs.py b/artiq/dashboard/experiment_tabs.py new file mode 100644 index 0000000000..c5e2ebe0cc --- /dev/null +++ b/artiq/dashboard/experiment_tabs.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 + +import logging +import os + +from PyQt6 import QtCore, QtGui, QtWidgets + +from artiq import __artiq_dir__ as artiq_dir +from artiq.gui.tools import EditableMdiTabBar + + +class MdiArea(QtWidgets.QMdiArea): + def __init__(self): + QtWidgets.QMdiArea.__init__(self) + self.pixmap = QtGui.QPixmap(os.path.join( + artiq_dir, "gui", "logo_ver.svg")) + + self.setActivationOrder( + QtWidgets.QMdiArea.WindowOrder.ActivationHistoryOrder) + + self.tile = QtGui.QShortcut( + QtGui.QKeySequence('Ctrl+Shift+T'), self) + self.tile.activated.connect( + lambda: self.tileSubWindows()) + + self.cascade = QtGui.QShortcut( + QtGui.QKeySequence('Ctrl+Shift+C'), self) + self.cascade.activated.connect( + lambda: self.cascadeSubWindows()) + self.setHorizontalScrollBarPolicy( + QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.setVerticalScrollBarPolicy( + QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded) + + def paintEvent(self, event): + QtWidgets.QMdiArea.paintEvent(self, event) + painter = QtGui.QPainter(self.viewport()) + x = (self.width() - self.pixmap.width()) // 2 + y = (self.height() - self.pixmap.height()) // 2 + painter.setOpacity(0.5) + painter.drawPixmap(x, y, self.pixmap) + + def setTabName(self, name): + self.tab_name = name + + +class MultipleTabsManagement: + def __init__(self, main_window): + self.tab_widget = QtWidgets.QTabWidget() + self.tab_widget.setTabBar(EditableMdiTabBar(main_window)) + self.tab_widget.setTabsClosable(True) + self.tab_widget.setMovable(True) + self.tab_widget.tabCloseRequested.connect(self.close_mdi_area) + main_window.setCentralWidget(self.tab_widget) + + plus_button = QtWidgets.QToolButton() + plus_button.setText("+") + plus_button.setToolTip("Add new workspace") + plus_button.clicked.connect(self.new_mdi_area) + self.tab_widget.setCornerWidget(plus_button, + QtCore.Qt.Corner.TopLeftCorner) + + self.add_mdi_area("Workspace 1") + + self.tab_widget.currentChanged.connect(self.on_tab_changed) + + def on_tab_changed(self, index): + # We want to refresh geometry to properly place minimized windows after + # resizing from other MDI area. + # It causes 2 other issues that are addressed here: + # 1. The focus stays on the minimized window. + # 2. If the code below executes, maximized windows get un-maximized - + # this is not obvious and seems to depend on MDI implementation. + mdi_area = self.tab_widget.widget(index) + # Check which subwindow is active + activeSubWindow = mdi_area.activeSubWindow() + # Check if active subwindow is maximized. If not, neither window is + # maximized + wasMaximized = activeSubWindow.isMaximized() if activeSubWindow else False + + for subwindow in mdi_area.subWindowList(): + # Refresh geometry to properly place minimized windows + if subwindow.isMinimized(): + subwindow.setWindowState(QtCore.Qt.WindowState.WindowNoState) + subwindow.setWindowState(QtCore.Qt.WindowState.WindowMinimized) + # Restore focus and maximization + if activeSubWindow: + mdi_area.setActiveSubWindow(activeSubWindow) + activeSubWindow.widget().setFocus() + if wasMaximized: + activeSubWindow.setWindowState(QtCore.Qt.WindowState.WindowMaximized) + + def add_mdi_area(self, title): + # Create a new MDI area (tab) with the given title + mdi_area = MdiArea() + mdi_area.setTabName(title) + index = self.tab_widget.addTab(mdi_area, title) + self.tab_widget.setTabToolTip(index, "Double click to rename") + + self.tab_widget.setTabsClosable(self.tab_widget.count() > 1) + + def tab_name_exists(self, name, ignore_index=None): + for i in range(self.tab_widget.count()): + if ignore_index is not None and i == ignore_index: + continue + widget = self.tab_widget.widget(i) + if hasattr(widget, "tab_name") and widget.tab_name == name: + return True + return False + + def new_mdi_area(self): + # Add a new MDI area (tab) with an auto-generated unique title + idx = 1 + title = f"Workspace {idx}" + while self.tab_name_exists(title): + idx = idx + 1 + title = f"Workspace {idx}" + self.add_mdi_area(title) + self.tab_widget.setCurrentIndex(self.tab_widget.count() - 1) + + def close_mdi_area(self, index): + if self.tab_widget.count() == 1: + logging.warning("Cannot close last workspace") + return + mdi_area = self.tab_widget.widget(index) + for experiment in mdi_area.subWindowList(): + mdi_area.removeSubWindow(experiment) + experiment.close() + self.tab_widget.removeTab(index) + mdi_area.deleteLater() + + self.tab_widget.setTabsClosable(self.tab_widget.count() > 1) + + def rename_mdi_area(self, index, title): + if self.tab_widget.count() < index + 1: + logging.warning("Requested workspace does not exist") + return + tab_bar = self.tab_widget.tabBar() + tab_bar.set_tab_name(index, title) + + def get_mdi_areas_state(self): + return [self.tab_widget.tabText(i) for i in range(self.tab_widget.count())] + + def restore_mdi_areas_state(self, mdi_areas_list): + for index, title in enumerate(mdi_areas_list): + if index == 0: + # The first workspace is created always in init in order to + # handle the case of no state to restore + self.rename_mdi_area(index, title) + else: + self.add_mdi_area(title) diff --git a/artiq/dashboard/experiments.py b/artiq/dashboard/experiments.py index bbc46040e0..beca291e13 100644 --- a/artiq/dashboard/experiments.py +++ b/artiq/dashboard/experiments.py @@ -683,21 +683,30 @@ def get_submission_arguments(self, expurl): def open_experiment(self, expurl): if expurl in self.open_experiments: dock = self.open_experiments[expurl] + mdi_area = dock.mdiArea() + if mdi_area is not None: + tab_widget = self.main_window.centralWidget() + tab_widget.setCurrentWidget(mdi_area) + mdi_area.setActiveSubWindow(dock) + if dock.isMinimized(): dock.showNormal() - self.main_window.centralWidget().setActiveSubWindow(dock) return dock try: dock = _ExperimentDock(self, expurl) - except: - logger.warning("Failed to create experiment dock for %s, " - "attempting to reset arguments", expurl, - exc_info=True) + except Exception: + logger.warning( + "Failed to create experiment dock for %s, attempting to reset arguments", + expurl, + exc_info=True, + ) del self.submission_arguments[expurl] dock = _ExperimentDock(self, expurl) self.open_experiments[expurl] = dock dock.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose) - self.main_window.centralWidget().addSubWindow(dock) + mdi_area = self.main_window.centralWidget().currentWidget() + if mdi_area is not None: + mdi_area.addSubWindow(dock) colors = self.get_colors(expurl) if colors: if "title_bar" in colors: @@ -709,10 +718,12 @@ def open_experiment(self, expurl): if expurl in self.dock_states: try: dock.restore_state(self.dock_states[expurl]) - except: - logger.warning("Failed to restore dock state when opening " - "experiment %s", expurl, - exc_info=True) + except Exception: + logger.warning( + "Failed to restore dock state when opening experiment %s", + expurl, + exc_info=True, + ) return dock def on_dock_closed(self, expurl): @@ -814,14 +825,21 @@ async def open_file(self, file): def save_state(self): for expurl, dock in self.open_experiments.items(): self.dock_states[expurl] = dock.save_state() + + mdi_experiment = {} + for index in range(self.main_window.centralWidget().count()): + mdi_area = self.main_window.centralWidget().widget(index) + mdi_experiment[index] = [] + for experiment in mdi_area.subWindowList(): + mdi_experiment[index].append(experiment.expurl) return { "scheduling": dict(self.submission_scheduling), "options": dict(self.submission_options), "arguments": dict(self.submission_arguments), "docks": dict(self.dock_states), "argument_uis": dict(self.argument_ui_names), - "open_docks": set(self.open_experiments.keys()), - "colors": dict(self.colors) + "colors": dict(self.colors), + "mdi_experiment": mdi_experiment } def restore_state(self, state): @@ -833,8 +851,12 @@ def restore_state(self, state): self.submission_arguments.update(state["arguments"]) self.argument_ui_names.update(state.get("argument_uis", {})) self.colors.update(state.get("colors", {})) - for expurl in state["open_docks"]: - self.open_experiment(expurl) + mdi_experiment = state.get("mdi_experiment", {}) + for index, mdi in mdi_experiment.items(): + mdi_area = self.main_window.centralWidget().widget(index) + self.main_window.centralWidget().setCurrentWidget(mdi_area) + for expurl in mdi: + self.open_experiment(expurl) def show_quick_open(self): if self.is_quick_open_shown: diff --git a/artiq/frontend/artiq_dashboard.py b/artiq/frontend/artiq_dashboard.py index 450be0ca43..74a5e3068c 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -20,7 +20,7 @@ from artiq.tools import get_user_config_dir from artiq.gui.models import ModelSubscriber from artiq.gui import state, log -from artiq.dashboard import (experiments, shortcuts, explorer, +from artiq.dashboard import (experiments, experiment_tabs, shortcuts, explorer, moninj, datasets, schedule, applets_ccb, waveform, interactive_args) @@ -74,47 +74,29 @@ def __init__(self, server): self.exit_request = asyncio.Event() + self.multiple_tabs_management = experiment_tabs.MultipleTabsManagement(self) + def closeEvent(self, event): event.ignore() self.exit_request.set() def save_state(self): + # Save MainWindow state including MDI areas. + # (This is separate from the QMainWindow state.) + mdi_areas = self.multiple_tabs_management.get_mdi_areas_state() return { "state": bytes(self.saveState()), - "geometry": bytes(self.saveGeometry()) + "geometry": bytes(self.saveGeometry()), + "mdi_areas": mdi_areas, } def restore_state(self, state): + # Restore MainWindow state including MDI areas self.restoreGeometry(QtCore.QByteArray(state["geometry"])) self.restoreState(QtCore.QByteArray(state["state"])) - - -class MdiArea(QtWidgets.QMdiArea): - def __init__(self): - QtWidgets.QMdiArea.__init__(self) - self.pixmap = QtGui.QPixmap(os.path.join( - artiq_dir, "gui", "logo_ver.svg")) - - self.setActivationOrder( - QtWidgets.QMdiArea.WindowOrder.ActivationHistoryOrder) - - self.tile = QtGui.QShortcut( - QtGui.QKeySequence('Ctrl+Shift+T'), self) - self.tile.activated.connect( - lambda: self.tileSubWindows()) - - self.cascade = QtGui.QShortcut( - QtGui.QKeySequence('Ctrl+Shift+C'), self) - self.cascade.activated.connect( - lambda: self.cascadeSubWindows()) - - def paintEvent(self, event): - QtWidgets.QMdiArea.paintEvent(self, event) - painter = QtGui.QPainter(self.viewport()) - x = (self.width() - self.pixmap.width()) // 2 - y = (self.height() - self.pixmap.height()) // 2 - painter.setOpacity(0.5) - painter.drawPixmap(x, y, self.pixmap) + self.multiple_tabs_management.restore_mdi_areas_state( + state.get("mdi_areas", []) + ) def main(): @@ -190,11 +172,6 @@ def report_disconnect(): # initialize main window main_window = MainWindow(args.server if server_name is None else server_name) - smgr.register(main_window) - mdi_area = MdiArea() - mdi_area.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded) - mdi_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded) - main_window.setCentralWidget(mdi_area) # create UI components expmgr = experiments.ExperimentManager(main_window, @@ -204,6 +181,7 @@ def report_disconnect(): rpc_clients["schedule"], rpc_clients["experiment_db"]) smgr.register(expmgr) + smgr.register(main_window) d_shortcuts = shortcuts.ShortcutsDock(main_window, expmgr) smgr.register(d_shortcuts) d_explorer = explorer.ExplorerDock(expmgr, d_shortcuts, diff --git a/artiq/gui/tools.py b/artiq/gui/tools.py index d055c6f21b..51079be635 100644 --- a/artiq/gui/tools.py +++ b/artiq/gui/tools.py @@ -164,3 +164,52 @@ def on_accept(): dialog.open() return await fut + +class EditableMdiTabBar(QtWidgets.QTabBar): + def __init__(self, parent): + super().__init__(parent) + self._editor = QtWidgets.QLineEdit(self) + self._editor.setWindowFlags(QtCore.Qt.WindowType.Popup) + self._editor.setFocusProxy(self) + self._editor.editingFinished.connect(self.handleEditingFinished) + self._editor.installEventFilter(self) + + def eventFilter(self, widget, event): + if ( + event.type() == QtCore.QEvent.Type.MouseButtonPress + and not self._editor.geometry().contains(event.globalPosition().toPoint()) + ) or ( + event.type() == QtCore.QEvent.Type.KeyPress + and event.key() == QtCore.Qt.Key.Key_Escape + ): + self._editor.hide() + return True + + return super().eventFilter(widget, event) + + def mouseDoubleClickEvent(self, event): + index = self.tabAt(event.pos()) + if index >= 0: + self.editTab(index) + + def editTab(self, index): + rect = self.tabRect(index) + self._editor.setFixedSize(rect.size()) + self._editor.move(self.mapToGlobal(rect.topLeft())) + self._editor.setText(self.tabText(index)) + if not self._editor.isVisible(): + self._editor.show() + + def handleEditingFinished(self): + index = self.currentIndex() + if index >= 0: + self._editor.hide() + self.set_tab_name(index, self._editor.text()) + + def set_tab_name(self, index, name): + self.setTabText(index, name) + tab_widget = self.parent() + mdi_area = tab_widget.widget(index) + mdi_area.setTabName(name) + +