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 17 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
42 changes: 31 additions & 11 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,18 @@ 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),
"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),
"experiment_mdi": experiment_mdi
}

def restore_state(self, state):
Expand All @@ -833,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):
Expand Down
160 changes: 154 additions & 6 deletions artiq/frontend/artiq_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,42 @@ def get_argparser():
return parser


def tab_name_exists(tab_widget, name, ignore_index=None):
Comment thread
mmalczak marked this conversation as resolved.
Outdated
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):
Comment thread
mmalczak marked this conversation as resolved.
Outdated
def mouseDoubleClickEvent(self, event):
index = self.tabAt(event.pos())
if index != -1:
current_name = self.tabText(index)
new_name, ok = QtWidgets.QInputDialog.getText(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am aware that QTabBar does not come with built in editing of the tab text (which is a shame). I do still think that an input dialog is overkill here. Spawning a QLineEdit and positioning it over the tab text seems more user friendly, see for example https://stackoverflow.com/q/8707457

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did it and pushed the changes.
Please check it, as I am not convinced if it is a better solution.

If I were to choose, I would go with the input dialog.

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 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)
super().mouseDoubleClickEvent(event)


class MainWindow(QtWidgets.QMainWindow):
def __init__(self, server):
QtWidgets.QMainWindow.__init__(self)
Expand All @@ -74,19 +110,128 @@ def __init__(self, server):

self.exit_request = asyncio.Event()

self.tab_widget = QtWidgets.QTabWidget()
Comment thread
mmalczak marked this conversation as resolved.
Outdated
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.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
Comment thread
mmalczak marked this conversation as resolved.
Outdated
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.
"""
Comment thread
mmalczak marked this conversation as resolved.
Outdated
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.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")
Comment thread
mmalczak marked this conversation as resolved.
Outdated
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():
Comment thread
mmalczak marked this conversation as resolved.
Outdated
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)

def get_mdi_area_by_name(self, name):
Comment thread
mmalczak marked this conversation as resolved.
Outdated
"""
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):
Expand All @@ -107,6 +252,10 @@ def __init__(self):
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)
Expand All @@ -116,6 +265,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
Expand Down Expand Up @@ -190,11 +342,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 +351,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