diff --git a/artiq/dashboard/experiments.py b/artiq/dashboard/experiments.py index 10bfa5a0c2..1746dcaf8c 100644 --- a/artiq/dashboard/experiments.py +++ b/artiq/dashboard/experiments.py @@ -576,30 +576,42 @@ 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.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) dock.show() dock.sigClosed.connect(partial(self.on_dock_closed, 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): @@ -701,13 +713,17 @@ 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() + experiment_mdi = {} + for expurl, dock in self.open_experiments.items(): + experiment_mdi[expurl] = dock.mdiArea().tab_name return { "scheduling": self.submission_scheduling, "options": self.submission_options, "arguments": self.submission_arguments, "docks": self.dock_states, "argument_uis": self.argument_ui_names, - "open_docks": set(self.open_experiments.keys()) + "open_docks": set(self.open_experiments.keys()), + "experiment_mdi": experiment_mdi } def restore_state(self, state): @@ -718,8 +734,21 @@ def restore_state(self, state): self.submission_options = state["options"] self.submission_arguments = state["arguments"] self.argument_ui_names = state.get("argument_uis", {}) - for expurl in state["open_docks"]: - self.open_experiment(expurl) + experiment_mdi = state.get("experiment_mdi", {}) + if 'experiment_mdi' in state: + for expurl in state["open_docks"]: + tab_widget = self.main_window.centralWidget() + mdi_area_name = experiment_mdi[expurl] + mdi_area = self.main_window.get_mdi_area_by_name(mdi_area_name) + tab_widget.setCurrentWidget(mdi_area) + self.open_experiment(expurl) + else: + # else statement is necessary for migration purpose + tab_widget = self.main_window.centralWidget() + mdi_area = self.main_window.new_mdi_area() + tab_widget.setCurrentWidget(mdi_area) + for expurl in state["open_docks"]: + 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 a0606c3915..993f8bc78d 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -61,6 +61,44 @@ def get_argparser(): return parser +def tab_name_exists(tab_widget, name, ignore_index=None): + for i in range(tab_widget.count()): + if ignore_index is not None and i == ignore_index: + continue + widget = tab_widget.widget(i) + if hasattr(widget, "tab_name") and widget.tab_name == name: + return True + return False + + +class EditableTabBar(QtWidgets.QTabBar): + def mouseDoubleClickEvent(self, event): + index = self.tabAt(event.pos()) + if index != -1: + current_name = self.tabText(index) + new_name, ok = QtWidgets.QInputDialog.getText( + self, "Rename Workspace", "Enter a new name:", + text=current_name + ) + if ok and new_name.strip(): + new_name = new_name.strip() + tab_widget = self.parent() + if isinstance(tab_widget, QtWidgets.QTabWidget): + if tab_name_exists(tab_widget, new_name, + ignore_index=index): + QtWidgets.QMessageBox.warning( + self, "Duplicate Tab Name", + "Another workspace already has that name. " + "Please choose a unique name." + ) + return + self.setTabText(index, new_name) + if isinstance(tab_widget, QtWidgets.QTabWidget): + mdi_area = tab_widget.widget(index) + mdi_area.setTabName(new_name) + super().mouseDoubleClickEvent(event) + + class MainWindow(QtWidgets.QMainWindow): def __init__(self, server): QtWidgets.QMainWindow.__init__(self) @@ -74,19 +112,137 @@ def __init__(self, server): self.exit_request = asyncio.Event() + self.tab_widget = QtWidgets.QTabWidget() + self.tab_widget.setTabBar(EditableTabBar()) + self.tab_widget.setTabsClosable(True) + self.tab_widget.tabCloseRequested.connect(self.close_mdi_area) + self.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.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) + if not mdi_area: + return + # 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 + + if isinstance(mdi_area, MdiArea): + for subwindow in mdi_area.subWindowList(): + # Refresh geometry to properly place minimized windows + if subwindow.isMinimized(): + subwindow.setWindowState(QtCore.Qt.WindowNoState) + subwindow.setWindowState(QtCore.Qt.WindowMinimized) + # Restore focus and maximization + if activeSubWindow: + mdi_area.setActiveSubWindow(activeSubWindow) + activeSubWindow.widget().setFocus() + if wasMaximized: + activeSubWindow.setWindowState(QtCore.Qt.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") + + def new_mdi_area(self): + """Add a new MDI area (tab) with an auto-generated unique title.""" + count = self.tab_widget.count() + 1 + title = f"Workspace {count}" + while tab_name_exists(self.tab_widget, title): + count = count + 1 + title = f"Workspace {count}" + self.add_mdi_area(title) + self.tab_widget.setCurrentIndex(self.tab_widget.count() - 1) + + def close_mdi_area(self, index): + """Handle closing an MDI area (tab).""" + 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() + 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.tab_widget.tabText(i) for i in range(self.tab_widget.count())] return { "state": bytes(self.saveState()), - "geometry": bytes(self.saveGeometry()) + "geometry": bytes(self.saveGeometry()), + "mdi_areas": mdi_areas, } + def _remove_init_mdi_areas(self): + """In order to handle the case of the first start of the + dashboard, we add new mdi_area in the init. It cannot be + done in restore_state because it is not called in that + special case. However, if the restore state is called, + we remove it. + """ + if self.tab_widget.count() == 1: + mdi_area = self.tab_widget.widget(0) + for experiment in mdi_area.subWindowList(): + mdi_area.removeSubWindow(experiment) + experiment.close() + self.tab_widget.removeTab(0) + mdi_area.deleteLater() + def restore_state(self, state): + """Restore MainWindow state including MDI areas.""" + self._remove_init_mdi_areas() + if self.tab_widget.count() == 1: + mdi_area = self.tab_widget.widget(0) + for experiment in mdi_area.subWindowList(): + mdi_area.removeSubWindow(experiment) + experiment.close() + self.tab_widget.removeTab(0) + mdi_area.deleteLater() self.restoreGeometry(QtCore.QByteArray(state["geometry"])) self.restoreState(QtCore.QByteArray(state["state"])) + for title in state.get("mdi_areas", []): + self.add_mdi_area(title) + + def get_mdi_area_by_name(self, name): + """ + Given a name (i.e. tab title), return the corresponding MDI area. + Returns None if not found. + """ + for i in range(self.tab_widget.count()): + if self.tab_widget.tabText(i) == name: + return self.tab_widget.widget(i) + return None class MdiArea(QtWidgets.QMdiArea): @@ -106,6 +262,8 @@ def __init__(self): QtGui.QKeySequence('Ctrl+Shift+C'), self) self.cascade.activated.connect( lambda: self.cascadeSubWindows()) + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) + self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) def paintEvent(self, event): QtWidgets.QMdiArea.paintEvent(self, event) @@ -115,6 +273,9 @@ def paintEvent(self, event): painter.setOpacity(0.5) painter.drawPixmap(x, y, self.pixmap) + def setTabName(self, name): + self.tab_name = name + def main(): # initialize application @@ -184,11 +345,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.ScrollBarAsNeeded) - mdi_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) - main_window.setCentralWidget(mdi_area) # create UI components expmgr = experiments.ExperimentManager(main_window, @@ -198,6 +354,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,