From fb6bde20a86231f456c2dfe0848e62c55637ed82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Sun, 2 Feb 2025 16:09:13 +0100 Subject: [PATCH 01/27] Add multiple MDI areas #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/dashboard/experiments.py | 31 +++++++++++++++------- artiq/frontend/artiq_dashboard.py | 44 +++++++++++++++++++++++++++---- 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/artiq/dashboard/experiments.py b/artiq/dashboard/experiments.py index bbc46040e0..cb455b61ea 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): diff --git a/artiq/frontend/artiq_dashboard.py b/artiq/frontend/artiq_dashboard.py index 450be0ca43..6fdff7104a 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -74,6 +74,43 @@ def __init__(self, server): self.exit_request = asyncio.Event() + self.tab_widget = QtWidgets.QTabWidget() + self.tab_widget.setTabsClosable(True) + self.tab_widget.tabCloseRequested.connect(self.close_mdi_area) + self.setCentralWidget(self.tab_widget) + self.add_mdi_area("Main Area") + toolbar = QtWidgets.QToolBar("Main Toolbar") + toolbar.setObjectName("MainToolbar") + self.addToolBar(toolbar) + + add_area_action = QtWidgets.QAction("New Workspace", self) + add_area_action.triggered.connect(self.new_mdi_area) + toolbar.addAction(add_area_action) + + def add_mdi_area(self, title): + """Create a new MDI area (tab) with the given title.""" + mdi_area = MdiArea() + self.tab_widget.addTab(mdi_area, title) + + def new_mdi_area(self): + """Add a new MDI area (tab) with an auto-generated title.""" + count = self.tab_widget.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() @@ -107,6 +144,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) @@ -116,7 +155,6 @@ def paintEvent(self, event): painter.setOpacity(0.5) painter.drawPixmap(x, y, self.pixmap) - def main(): # initialize application args = get_argparser().parse_args() @@ -191,10 +229,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, From c50d664183d125d84457945bdddd8c6c99367124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Sun, 2 Feb 2025 22:59:36 +0100 Subject: [PATCH 02/27] Add double-click action to change the name of the MDI area #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/frontend/artiq_dashboard.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/artiq/frontend/artiq_dashboard.py b/artiq/frontend/artiq_dashboard.py index 6fdff7104a..8fcdab4f9d 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -61,6 +61,25 @@ def get_argparser(): return parser +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() + self.setTabText(index, new_name) + tab_widget = self.parent() + if isinstance(tab_widget, QtWidgets.QTabWidget): + mdi_area = tab_widget.widget(index) + mdi_area.tab_name = new_name + super().mouseDoubleClickEvent(event) + + class MainWindow(QtWidgets.QMainWindow): def __init__(self, server): QtWidgets.QMainWindow.__init__(self) @@ -75,6 +94,7 @@ 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) From 6f43ea93ad7281d2e7d85db6b49d1177c76cc1dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Mon, 3 Feb 2025 22:33:28 +0100 Subject: [PATCH 03/27] Restore the state of the MainWindow before restoring the state of the ExperimentManager #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/dashboard/experiments.py | 11 ++++++++++- artiq/frontend/artiq_dashboard.py | 25 ++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/artiq/dashboard/experiments.py b/artiq/dashboard/experiments.py index cb455b61ea..230705e4ab 100644 --- a/artiq/dashboard/experiments.py +++ b/artiq/dashboard/experiments.py @@ -825,6 +825,9 @@ 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": dict(self.submission_scheduling), "options": dict(self.submission_options), @@ -832,7 +835,8 @@ def save_state(self): "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), + "experiment_mdi": experiment_mdi } def restore_state(self, state): @@ -844,7 +848,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", {})) + experiment_mdi = state.get("experiment_mdi", {}) 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) def show_quick_open(self): diff --git a/artiq/frontend/artiq_dashboard.py b/artiq/frontend/artiq_dashboard.py index 8fcdab4f9d..cbaf2bfa11 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -98,7 +98,6 @@ def __init__(self, server): self.tab_widget.setTabsClosable(True) self.tab_widget.tabCloseRequested.connect(self.close_mdi_area) self.setCentralWidget(self.tab_widget) - self.add_mdi_area("Main Area") toolbar = QtWidgets.QToolBar("Main Toolbar") toolbar.setObjectName("MainToolbar") self.addToolBar(toolbar) @@ -110,6 +109,7 @@ def __init__(self, server): def add_mdi_area(self, title): """Create a new MDI area (tab) with the given title.""" mdi_area = MdiArea() + mdi_area.tab_name = title # store the name for later lookup self.tab_widget.addTab(mdi_area, title) def new_mdi_area(self): @@ -136,14 +136,33 @@ def closeEvent(self, event): 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 restore_state(self, state): + """Restore MainWindow state including MDI areas.""" 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): @@ -248,7 +267,6 @@ def report_disconnect(): # initialize main window main_window = MainWindow(args.server if server_name is None else server_name) - smgr.register(main_window) # create UI components expmgr = experiments.ExperimentManager(main_window, @@ -258,6 +276,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, From 0cd673baeb24519d6e4811b893915895d9d511d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Tue, 4 Feb 2025 22:52:12 +0100 Subject: [PATCH 04/27] Avoid tab-names duplication #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/frontend/artiq_dashboard.py | 37 ++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/artiq/frontend/artiq_dashboard.py b/artiq/frontend/artiq_dashboard.py index cbaf2bfa11..3e3e3a86c4 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -61,6 +61,16 @@ 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()) @@ -72,14 +82,20 @@ def mouseDoubleClickEvent(self, event): ) if ok and new_name.strip(): new_name = new_name.strip() - self.setTabText(index, new_name) 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.tab_name = new_name + mdi_area.setTabName(new_name) super().mouseDoubleClickEvent(event) - class MainWindow(QtWidgets.QMainWindow): def __init__(self, server): QtWidgets.QMainWindow.__init__(self) @@ -107,10 +123,15 @@ def __init__(self, server): toolbar.addAction(add_area_action) def add_mdi_area(self, title): - """Create a new MDI area (tab) with the given title.""" + """Create a new MDI area (tab) with the given title, ensuring uniqueness.""" + unique_title = title + counter = 1 + while tab_name_exists(self.tab_widget, unique_title): + unique_title = f"{title} ({counter})" + counter += 1 mdi_area = MdiArea() - mdi_area.tab_name = title # store the name for later lookup - self.tab_widget.addTab(mdi_area, title) + mdi_area.setTabName(unique_title) + self.tab_widget.addTab(mdi_area, unique_title) def new_mdi_area(self): """Add a new MDI area (tab) with an auto-generated title.""" @@ -194,6 +215,10 @@ 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 args = get_argparser().parse_args() From 53418d9ca89b5b8f4ee38b70af81163173923983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Fri, 7 Feb 2025 20:45:01 +0100 Subject: [PATCH 05/27] Handle the case of missing mda_area on fresh dashboard #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/frontend/artiq_dashboard.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/artiq/frontend/artiq_dashboard.py b/artiq/frontend/artiq_dashboard.py index 3e3e3a86c4..dbf2302a3d 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -121,6 +121,7 @@ def __init__(self, server): add_area_action = QtWidgets.QAction("New Workspace", self) add_area_action.triggered.connect(self.new_mdi_area) toolbar.addAction(add_area_action) + self.add_mdi_area("Workspace 1") def add_mdi_area(self, title): """Create a new MDI area (tab) with the given title, ensuring uniqueness.""" @@ -168,8 +169,31 @@ def save_state(self): "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", []): From f099bc5d018b655b7740fbdfede09b8207e98d9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Fri, 7 Feb 2025 22:02:06 +0100 Subject: [PATCH 06/27] Replace New Workspace button with plus sign #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/frontend/artiq_dashboard.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/artiq/frontend/artiq_dashboard.py b/artiq/frontend/artiq_dashboard.py index dbf2302a3d..35c07d8eb7 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -114,13 +114,13 @@ def __init__(self, server): self.tab_widget.setTabsClosable(True) self.tab_widget.tabCloseRequested.connect(self.close_mdi_area) self.setCentralWidget(self.tab_widget) - toolbar = QtWidgets.QToolBar("Main Toolbar") - toolbar.setObjectName("MainToolbar") - self.addToolBar(toolbar) - add_area_action = QtWidgets.QAction("New Workspace", self) - add_area_action.triggered.connect(self.new_mdi_area) - toolbar.addAction(add_area_action) + 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") def add_mdi_area(self, title): From cc7caca68807b951c4f9ab41c4133cbf981366ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Fri, 7 Feb 2025 22:07:45 +0100 Subject: [PATCH 07/27] Add tab tooltip - double click to rename #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/frontend/artiq_dashboard.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/artiq/frontend/artiq_dashboard.py b/artiq/frontend/artiq_dashboard.py index 35c07d8eb7..5dbea06fed 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -132,7 +132,8 @@ def add_mdi_area(self, title): counter += 1 mdi_area = MdiArea() mdi_area.setTabName(unique_title) - self.tab_widget.addTab(mdi_area, unique_title) + index = self.tab_widget.addTab(mdi_area, unique_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 title.""" From be4f61b97560e9d022ffe3ff04fe06d250a8146d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Fri, 7 Feb 2025 22:23:41 +0100 Subject: [PATCH 08/27] Improve assignment of unique tab titles #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/frontend/artiq_dashboard.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/artiq/frontend/artiq_dashboard.py b/artiq/frontend/artiq_dashboard.py index 5dbea06fed..fcdfce4aea 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -124,21 +124,19 @@ def __init__(self, server): self.add_mdi_area("Workspace 1") def add_mdi_area(self, title): - """Create a new MDI area (tab) with the given title, ensuring uniqueness.""" - unique_title = title - counter = 1 - while tab_name_exists(self.tab_widget, unique_title): - unique_title = f"{title} ({counter})" - counter += 1 + """Create a new MDI area (tab) with the given title.""" mdi_area = MdiArea() - mdi_area.setTabName(unique_title) - index = self.tab_widget.addTab(mdi_area, unique_title) + 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 title.""" + """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) From f3fb5bcd2ec45581419e307bc849fc390c934f65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Tue, 11 Feb 2025 17:01:45 +0100 Subject: [PATCH 09/27] Force geomettry refresh on minimized windows on tab-change #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/frontend/artiq_dashboard.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/artiq/frontend/artiq_dashboard.py b/artiq/frontend/artiq_dashboard.py index fcdfce4aea..97750232e7 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -115,6 +115,8 @@ def __init__(self, server): self.tab_widget.tabCloseRequested.connect(self.close_mdi_area) self.setCentralWidget(self.tab_widget) + self.tab_widget.currentChanged.connect(self.on_tab_changed) + plus_button = QtWidgets.QToolButton() plus_button.setText("+") plus_button.setToolTip("Add new workspace") @@ -123,6 +125,14 @@ def __init__(self, server): self.add_mdi_area("Workspace 1") + def on_tab_changed(self, index): + mdi_area = self.tab_widget.widget(index) + if isinstance(mdi_area, MdiArea): + for subwindow in mdi_area.subWindowList(): + if subwindow.isMinimized(): + subwindow.setWindowState(QtCore.Qt.WindowNoState) + subwindow.setWindowState(QtCore.Qt.WindowMinimized) + def add_mdi_area(self, title): """Create a new MDI area (tab) with the given title.""" mdi_area = MdiArea() From e4ca6912372c811a64eecc5fa37398a0639e8d73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Thu, 6 Mar 2025 01:23:50 +0100 Subject: [PATCH 10/27] Keep focus on the last focused subwindow, instead of the minimized one #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/frontend/artiq_dashboard.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/artiq/frontend/artiq_dashboard.py b/artiq/frontend/artiq_dashboard.py index 97750232e7..1c1b47649d 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -127,11 +127,15 @@ def __init__(self, server): def on_tab_changed(self, index): mdi_area = self.tab_widget.widget(index) + activeSubWindow = mdi_area.activeSubWindow() if isinstance(mdi_area, MdiArea): for subwindow in mdi_area.subWindowList(): if subwindow.isMinimized(): subwindow.setWindowState(QtCore.Qt.WindowNoState) subwindow.setWindowState(QtCore.Qt.WindowMinimized) + if activeSubWindow: + mdi_area.setActiveSubWindow(activeSubWindow) + activeSubWindow.widget().setFocus() def add_mdi_area(self, title): """Create a new MDI area (tab) with the given title.""" From f4503393fd716c26452f5ca6c822bb70b5632729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Thu, 6 Mar 2025 23:42:48 +0100 Subject: [PATCH 11/27] Keep the state of maximized windows, add comments #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/frontend/artiq_dashboard.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/artiq/frontend/artiq_dashboard.py b/artiq/frontend/artiq_dashboard.py index 1c1b47649d..bb13177444 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -126,16 +126,32 @@ def __init__(self, server): self.add_mdi_area("Workspace 1") 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 + 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.""" From a7873ad402e009e1e72c45202a4dd04708c5dbfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Sun, 9 Mar 2025 01:20:08 +0100 Subject: [PATCH 12/27] Support migration from release-8 to the version with multiple panes #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/dashboard/experiments.py | 16 ++++++++++++---- artiq/frontend/artiq_dashboard.py | 6 ++++-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/artiq/dashboard/experiments.py b/artiq/dashboard/experiments.py index 230705e4ab..98e8969f9f 100644 --- a/artiq/dashboard/experiments.py +++ b/artiq/dashboard/experiments.py @@ -849,12 +849,20 @@ def restore_state(self, state): self.argument_ui_names.update(state.get("argument_uis", {})) self.colors.update(state.get("colors", {})) experiment_mdi = state.get("experiment_mdi", {}) - for expurl in state["open_docks"]: + 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_name = experiment_mdi[expurl] - mdi_area = self.main_window.get_mdi_area_by_name(mdi_area_name) + mdi_area = self.main_window.new_mdi_area() tab_widget.setCurrentWidget(mdi_area) - self.open_experiment(expurl) + 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 bb13177444..df005d9a4c 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -115,8 +115,6 @@ def __init__(self, server): self.tab_widget.tabCloseRequested.connect(self.close_mdi_area) self.setCentralWidget(self.tab_widget) - self.tab_widget.currentChanged.connect(self.on_tab_changed) - plus_button = QtWidgets.QToolButton() plus_button.setText("+") plus_button.setToolTip("Add new workspace") @@ -125,6 +123,8 @@ def __init__(self, server): 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 @@ -135,6 +135,8 @@ def on_tab_changed(self, index): 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 From 760221715e5d451e44451bf6fb1206cf15e91dba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Tue, 15 Apr 2025 20:51:21 +0200 Subject: [PATCH 13/27] Linter changes #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/frontend/artiq_dashboard.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/artiq/frontend/artiq_dashboard.py b/artiq/frontend/artiq_dashboard.py index df005d9a4c..9ba12eb3e0 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -84,10 +84,12 @@ def mouseDoubleClickEvent(self, event): 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): + 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." + "Another workspace already has that name. " + "Please choose a unique name." ) return self.setTabText(index, new_name) @@ -96,6 +98,7 @@ def mouseDoubleClickEvent(self, event): mdi_area.setTabName(new_name) super().mouseDoubleClickEvent(event) + class MainWindow(QtWidgets.QMainWindow): def __init__(self, server): QtWidgets.QMainWindow.__init__(self) @@ -127,19 +130,20 @@ def __init__(self, server): def on_tab_changed(self, index): """ - We want to refresh geometry to properly place minimized windows after resizing - from other MDI area. + 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. + 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 + # Check if active subwindow is maximized. If not, neither window is + # maximized wasMaximized = activeSubWindow.isMaximized() if activeSubWindow else False if isinstance(mdi_area, MdiArea): From 3869ce27b4402b2396692b6469f38419df0a188f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Wed, 16 Apr 2025 00:39:27 +0200 Subject: [PATCH 14/27] Adjust for PyQt6 #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/frontend/artiq_dashboard.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/artiq/frontend/artiq_dashboard.py b/artiq/frontend/artiq_dashboard.py index 9ba12eb3e0..965e94a971 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -122,7 +122,8 @@ def __init__(self, server): 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.tab_widget.setCornerWidget(plus_button, + QtCore.Qt.Corner.TopLeftCorner) self.add_mdi_area("Workspace 1") @@ -263,8 +264,10 @@ 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) + self.setHorizontalScrollBarPolicy( + QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.setVerticalScrollBarPolicy( + QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded) def paintEvent(self, event): QtWidgets.QMdiArea.paintEvent(self, event) From 4a4ffb57f0142a375aa5a6fd0f41783664b54a49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Wed, 4 Jun 2025 21:40:43 +0200 Subject: [PATCH 15/27] Do not accommodate state migration between releases #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/dashboard/experiments.py | 16 ++++------------ artiq/frontend/artiq_dashboard.py | 2 -- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/artiq/dashboard/experiments.py b/artiq/dashboard/experiments.py index 98e8969f9f..230705e4ab 100644 --- a/artiq/dashboard/experiments.py +++ b/artiq/dashboard/experiments.py @@ -849,20 +849,12 @@ def restore_state(self, state): self.argument_ui_names.update(state.get("argument_uis", {})) self.colors.update(state.get("colors", {})) 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 + for expurl in state["open_docks"]: tab_widget = self.main_window.centralWidget() - mdi_area = self.main_window.new_mdi_area() + mdi_area_name = experiment_mdi[expurl] + mdi_area = self.main_window.get_mdi_area_by_name(mdi_area_name) tab_widget.setCurrentWidget(mdi_area) - for expurl in state["open_docks"]: - self.open_experiment(expurl) + 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 965e94a971..bf66d935b4 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -139,8 +139,6 @@ def on_tab_changed(self, index): 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 From b4a372fae3ee5e5bab6f04508e39224a2ec5fa43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Wed, 4 Jun 2025 22:07:41 +0200 Subject: [PATCH 16/27] Remove unnecessary 'isinstance' checks #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/frontend/artiq_dashboard.py | 33 ++++++++++++++----------------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/artiq/frontend/artiq_dashboard.py b/artiq/frontend/artiq_dashboard.py index bf66d935b4..1826b23d13 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -83,19 +83,17 @@ def mouseDoubleClickEvent(self, event): 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 + 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) + mdi_area = tab_widget.widget(index) + mdi_area.setTabName(new_name) super().mouseDoubleClickEvent(event) @@ -145,12 +143,11 @@ def on_tab_changed(self, index): # 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) + 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) From aa71de0978ac3461d58b04e3d1f8ae56dc4a4268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Wed, 4 Jun 2025 22:13:43 +0200 Subject: [PATCH 17/27] Remove duplicated code #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/frontend/artiq_dashboard.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/artiq/frontend/artiq_dashboard.py b/artiq/frontend/artiq_dashboard.py index 1826b23d13..632be756aa 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -218,13 +218,6 @@ def _remove_init_mdi_areas(self): 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", []): From 24e51e32445884bece61063f9507525938ad4358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Wed, 4 Jun 2025 23:01:40 +0200 Subject: [PATCH 18/27] PyQt6 adjustments #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/frontend/artiq_dashboard.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/artiq/frontend/artiq_dashboard.py b/artiq/frontend/artiq_dashboard.py index 632be756aa..2da907251d 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -146,14 +146,14 @@ def on_tab_changed(self, index): 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) + 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.WindowMaximized) + activeSubWindow.setWindowState(QtCore.Qt.WindowState.WindowMaximized) def add_mdi_area(self, title): """Create a new MDI area (tab) with the given title.""" From 59fc6951c0a5962d8a1b3b83d2520e15770e0ba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Fri, 6 Jun 2025 23:47:39 +0200 Subject: [PATCH 19/27] Save mdi area state using indexes from the centralWidget. Remove get_mdi_area_by_name which becomes redundant now #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/dashboard/experiments.py | 26 ++++++++++++++------------ artiq/frontend/artiq_dashboard.py | 10 ---------- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/artiq/dashboard/experiments.py b/artiq/dashboard/experiments.py index 230705e4ab..beca291e13 100644 --- a/artiq/dashboard/experiments.py +++ b/artiq/dashboard/experiments.py @@ -825,18 +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() - experiment_mdi = {} - for expurl, dock in self.open_experiments.items(): - experiment_mdi[expurl] = dock.mdiArea().tab_name + + 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), - "experiment_mdi": experiment_mdi + "mdi_experiment": mdi_experiment } def restore_state(self, state): @@ -848,13 +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", {})) - experiment_mdi = state.get("experiment_mdi", {}) - 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) + 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 2da907251d..582f93ec11 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -223,16 +223,6 @@ def restore_state(self, 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): def __init__(self): From fbf8ec32018d40713e0b176088c7356ce28cfe68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Sat, 7 Jun 2025 00:10:01 +0200 Subject: [PATCH 20/27] Do not enforce unique tab names #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/frontend/artiq_dashboard.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/artiq/frontend/artiq_dashboard.py b/artiq/frontend/artiq_dashboard.py index 582f93ec11..4c4740bd57 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -83,14 +83,6 @@ def mouseDoubleClickEvent(self, event): if ok and new_name.strip(): new_name = new_name.strip() tab_widget = self.parent() - 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) mdi_area = tab_widget.widget(index) mdi_area.setTabName(new_name) @@ -164,11 +156,11 @@ def add_mdi_area(self, title): 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}" + idx = 1 + title = f"Workspace {idx}" while tab_name_exists(self.tab_widget, title): - count = count + 1 - title = f"Workspace {count}" + idx = idx + 1 + title = f"Workspace {idx}" self.add_mdi_area(title) self.tab_widget.setCurrentIndex(self.tab_widget.count() - 1) From 8951907e8d15f944751091b3093b66b254a1fe2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Sat, 7 Jun 2025 22:21:01 +0200 Subject: [PATCH 21/27] Hide the close button when there is only one workspace left #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/frontend/artiq_dashboard.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/artiq/frontend/artiq_dashboard.py b/artiq/frontend/artiq_dashboard.py index 4c4740bd57..b14490e9a6 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -154,6 +154,8 @@ def add_mdi_area(self, 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 new_mdi_area(self): """Add a new MDI area (tab) with an auto-generated unique title.""" idx = 1 @@ -176,6 +178,8 @@ def close_mdi_area(self, index): self.tab_widget.removeTab(index) mdi_area.deleteLater() + self.tab_widget.setTabsClosable(self.tab_widget.count() > 1) + def closeEvent(self, event): event.ignore() self.exit_request.set() From 5354921e02d7d63226ac0d30efac17454e111d9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Sat, 7 Jun 2025 22:57:55 +0200 Subject: [PATCH 22/27] Rename the first Workspace instead of deleteing it when restoring the state #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/frontend/artiq_dashboard.py | 38 ++++++++++++++++--------------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/artiq/frontend/artiq_dashboard.py b/artiq/frontend/artiq_dashboard.py index b14490e9a6..82ed5219be 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -88,6 +88,12 @@ def mouseDoubleClickEvent(self, event): mdi_area.setTabName(new_name) super().mouseDoubleClickEvent(event) + 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) + class MainWindow(QtWidgets.QMainWindow): def __init__(self, server): @@ -180,6 +186,13 @@ def close_mdi_area(self, index): 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 closeEvent(self, event): event.ignore() self.exit_request.set() @@ -196,28 +209,17 @@ def save_state(self): "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() self.restoreGeometry(QtCore.QByteArray(state["geometry"])) self.restoreState(QtCore.QByteArray(state["state"])) - for title in state.get("mdi_areas", []): - self.add_mdi_area(title) + for index, title in enumerate(state.get("mdi_areas", [])): + 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) class MdiArea(QtWidgets.QMdiArea): From a2dfde14b03247388f37ce31161b7e18f04686f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Sat, 7 Jun 2025 23:50:31 +0200 Subject: [PATCH 23/27] Spawn a QLineEdit and positioning it over the tab text instead of using input dialog to change the tab name #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/frontend/artiq_dashboard.py | 52 ++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/artiq/frontend/artiq_dashboard.py b/artiq/frontend/artiq_dashboard.py index 82ed5219be..07bd11d6db 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -72,21 +72,45 @@ def tab_name_exists(tab_widget, name, ignore_index=None): class EditableTabBar(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 != -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() - self.setTabText(index, new_name) - mdi_area = tab_widget.widget(index) - mdi_area.setTabName(new_name) - super().mouseDoubleClickEvent(event) + 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) @@ -109,7 +133,7 @@ def __init__(self, server): self.exit_request = asyncio.Event() self.tab_widget = QtWidgets.QTabWidget() - self.tab_widget.setTabBar(EditableTabBar()) + self.tab_widget.setTabBar(EditableTabBar(self)) self.tab_widget.setTabsClosable(True) self.tab_widget.tabCloseRequested.connect(self.close_mdi_area) self.setCentralWidget(self.tab_widget) From 507ed755c413acca5b61401f06aedb285b90c1c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Sat, 7 Jun 2025 23:58:00 +0200 Subject: [PATCH 24/27] Remove docstrings or replace it with standard comments #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/frontend/artiq_dashboard.py | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/artiq/frontend/artiq_dashboard.py b/artiq/frontend/artiq_dashboard.py index 07bd11d6db..2fd0df4bc6 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -150,14 +150,12 @@ def __init__(self, server): 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. - """ + # 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() @@ -178,7 +176,7 @@ def on_tab_changed(self, index): activeSubWindow.setWindowState(QtCore.Qt.WindowState.WindowMaximized) def add_mdi_area(self, title): - """Create a new MDI area (tab) with the given 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) @@ -187,7 +185,7 @@ def add_mdi_area(self, title): self.tab_widget.setTabsClosable(self.tab_widget.count() > 1) def new_mdi_area(self): - """Add a new MDI area (tab) with an auto-generated unique title.""" + # Add a new MDI area (tab) with an auto-generated unique title idx = 1 title = f"Workspace {idx}" while tab_name_exists(self.tab_widget, title): @@ -197,7 +195,6 @@ def new_mdi_area(self): 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 @@ -222,10 +219,8 @@ def closeEvent(self, event): self.exit_request.set() def save_state(self): - """ - Save MainWindow state including MDI areas. - (This is separate from the QMainWindow state.) - """ + # 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()), @@ -234,7 +229,7 @@ def save_state(self): } def restore_state(self, state): - """Restore MainWindow state including MDI areas.""" + # Restore MainWindow state including MDI areas self.restoreGeometry(QtCore.QByteArray(state["geometry"])) self.restoreState(QtCore.QByteArray(state["state"])) for index, title in enumerate(state.get("mdi_areas", [])): From cc16f7ebe6d0934519098b2062bb35843b9585ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Sun, 8 Jun 2025 00:03:36 +0200 Subject: [PATCH 25/27] Move EditableMdiTabBar to artiq/gui/tools.py #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/frontend/artiq_dashboard.py | 51 ++----------------------------- artiq/gui/tools.py | 49 +++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 49 deletions(-) diff --git a/artiq/frontend/artiq_dashboard.py b/artiq/frontend/artiq_dashboard.py index 2fd0df4bc6..2a6ec9ab0e 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -19,6 +19,7 @@ from artiq import __artiq_dir__ as artiq_dir, __version__ as artiq_version from artiq.tools import get_user_config_dir from artiq.gui.models import ModelSubscriber +from artiq.gui.tools import EditableMdiTabBar from artiq.gui import state, log from artiq.dashboard import (experiments, shortcuts, explorer, moninj, datasets, schedule, applets_ccb, @@ -71,54 +72,6 @@ def tab_name_exists(tab_widget, name, ignore_index=None): return False -class EditableTabBar(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) - - class MainWindow(QtWidgets.QMainWindow): def __init__(self, server): QtWidgets.QMainWindow.__init__(self) @@ -133,7 +86,7 @@ def __init__(self, server): self.exit_request = asyncio.Event() self.tab_widget = QtWidgets.QTabWidget() - self.tab_widget.setTabBar(EditableTabBar(self)) + self.tab_widget.setTabBar(EditableMdiTabBar(self)) self.tab_widget.setTabsClosable(True) self.tab_widget.tabCloseRequested.connect(self.close_mdi_area) self.setCentralWidget(self.tab_widget) 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) + + From deb8f4d2d334d34fa2192574097ae00f274b382a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Sun, 8 Jun 2025 16:50:35 +0200 Subject: [PATCH 26/27] Encapsulate the 'multiple panes' logic in MultipleTabsManagement class and move it to artiq/dashboard/experiment_tabs.py #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/dashboard/experiment_tabs.py | 150 +++++++++++++++++++++++++++++ artiq/frontend/artiq_dashboard.py | 142 ++------------------------- 2 files changed, 156 insertions(+), 136 deletions(-) create mode 100644 artiq/dashboard/experiment_tabs.py diff --git a/artiq/dashboard/experiment_tabs.py b/artiq/dashboard/experiment_tabs.py new file mode 100644 index 0000000000..1becc3d297 --- /dev/null +++ b/artiq/dashboard/experiment_tabs.py @@ -0,0 +1,150 @@ +#!/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.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/frontend/artiq_dashboard.py b/artiq/frontend/artiq_dashboard.py index 2a6ec9ab0e..74a5e3068c 100755 --- a/artiq/frontend/artiq_dashboard.py +++ b/artiq/frontend/artiq_dashboard.py @@ -19,9 +19,8 @@ from artiq import __artiq_dir__ as artiq_dir, __version__ as artiq_version from artiq.tools import get_user_config_dir from artiq.gui.models import ModelSubscriber -from artiq.gui.tools import EditableMdiTabBar 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) @@ -62,16 +61,6 @@ 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 MainWindow(QtWidgets.QMainWindow): def __init__(self, server): QtWidgets.QMainWindow.__init__(self) @@ -85,87 +74,7 @@ def __init__(self, server): self.exit_request = asyncio.Event() - self.tab_widget = QtWidgets.QTabWidget() - self.tab_widget.setTabBar(EditableMdiTabBar(self)) - 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.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 new_mdi_area(self): - # Add a new MDI area (tab) with an auto-generated unique title - idx = 1 - title = f"Workspace {idx}" - while tab_name_exists(self.tab_widget, 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) + self.multiple_tabs_management = experiment_tabs.MultipleTabsManagement(self) def closeEvent(self, event): event.ignore() @@ -174,7 +83,7 @@ def closeEvent(self, event): 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())] + mdi_areas = self.multiple_tabs_management.get_mdi_areas_state() return { "state": bytes(self.saveState()), "geometry": bytes(self.saveGeometry()), @@ -185,48 +94,9 @@ def restore_state(self, state): # Restore MainWindow state including MDI areas self.restoreGeometry(QtCore.QByteArray(state["geometry"])) self.restoreState(QtCore.QByteArray(state["state"])) - for index, title in enumerate(state.get("mdi_areas", [])): - 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) - - -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 + self.multiple_tabs_management.restore_mdi_areas_state( + state.get("mdi_areas", []) + ) def main(): From bf3ed36501175cbae997d7d8c20f3046f5253d24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Sun, 15 Jun 2025 00:21:20 +0200 Subject: [PATCH 27/27] Make the tabs movable #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miłosz Malczak --- artiq/dashboard/experiment_tabs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/artiq/dashboard/experiment_tabs.py b/artiq/dashboard/experiment_tabs.py index 1becc3d297..c5e2ebe0cc 100644 --- a/artiq/dashboard/experiment_tabs.py +++ b/artiq/dashboard/experiment_tabs.py @@ -49,6 +49,7 @@ 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)