Skip to content
This repository was archived by the owner on Jan 18, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
fb6bde2
Add multiple MDI areas #4
mmalczak Feb 2, 2025
c50d664
Add double-click action to change the name of the MDI area #4
mmalczak Feb 2, 2025
6f43ea9
Restore the state of the MainWindow before restoring the state of the…
mmalczak Feb 3, 2025
0cd673b
Avoid tab-names duplication #4
mmalczak Feb 4, 2025
53418d9
Handle the case of missing mda_area on fresh dashboard #4
mmalczak Feb 7, 2025
f099bc5
Replace New Workspace button with plus sign #4
mmalczak Feb 7, 2025
cc7caca
Add tab tooltip - double click to rename #4
mmalczak Feb 7, 2025
be4f61b
Improve assignment of unique tab titles #4
mmalczak Feb 7, 2025
f3fb5bc
Force geomettry refresh on minimized windows on tab-change #4
mmalczak Feb 11, 2025
e4ca691
Keep focus on the last focused subwindow, instead of the minimized on…
mmalczak Mar 6, 2025
f450339
Keep the state of maximized windows, add comments #4
mmalczak Mar 6, 2025
a7873ad
Support migration from release-8 to the version with multiple panes #4
mmalczak Mar 9, 2025
7602217
Linter changes #4
mmalczak Apr 15, 2025
3869ce2
Adjust for PyQt6 #4
mmalczak Apr 15, 2025
4a4ffb5
Do not accommodate state migration between releases #4
mmalczak Jun 4, 2025
b4a372f
Remove unnecessary 'isinstance' checks #4
mmalczak Jun 4, 2025
aa71de0
Remove duplicated code #4
mmalczak Jun 4, 2025
24e51e3
PyQt6 adjustments #4
mmalczak Jun 4, 2025
59fc695
Save mdi area state using indexes from the centralWidget. Remove get_…
mmalczak Jun 6, 2025
fbf8ec3
Do not enforce unique tab names #4
mmalczak Jun 6, 2025
8951907
Hide the close button when there is only one workspace left #4
mmalczak Jun 7, 2025
5354921
Rename the first Workspace instead of deleteing it when restoring the…
mmalczak Jun 7, 2025
a2dfde1
Spawn a QLineEdit and positioning it over the tab text instead of usi…
mmalczak Jun 7, 2025
507ed75
Remove docstrings or replace it with standard comments #4
mmalczak Jun 7, 2025
cc16f7e
Move EditableMdiTabBar to artiq/gui/tools.py #4
mmalczak Jun 7, 2025
deb8f4d
Encapsulate the 'multiple panes' logic in MultipleTabsManagement clas…
mmalczak Jun 8, 2025
bf3ed36
Make the tabs movable #4
mmalczak Jun 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions artiq/dashboard/experiment_tabs.py
Original file line number Diff line number Diff line change
@@ -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)
50 changes: 36 additions & 14 deletions artiq/dashboard/experiments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand All @@ -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:
Expand Down
48 changes: 13 additions & 35 deletions artiq/frontend/artiq_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
49 changes: 49 additions & 0 deletions artiq/gui/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)