This repository was archived by the owner on Jan 18, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 229
Multiple panes qt6 #2721
Open
mmalczak
wants to merge
27
commits into
m-labs:master
Choose a base branch
from
elhep:multiple_panes_qt6
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Multiple panes qt6 #2721
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 c50d664
Add double-click action to change the name of the MDI area #4
mmalczak 6f43ea9
Restore the state of the MainWindow before restoring the state of the…
mmalczak 0cd673b
Avoid tab-names duplication #4
mmalczak 53418d9
Handle the case of missing mda_area on fresh dashboard #4
mmalczak f099bc5
Replace New Workspace button with plus sign #4
mmalczak cc7caca
Add tab tooltip - double click to rename #4
mmalczak be4f61b
Improve assignment of unique tab titles #4
mmalczak f3fb5bc
Force geomettry refresh on minimized windows on tab-change #4
mmalczak e4ca691
Keep focus on the last focused subwindow, instead of the minimized on…
mmalczak f450339
Keep the state of maximized windows, add comments #4
mmalczak a7873ad
Support migration from release-8 to the version with multiple panes #4
mmalczak 7602217
Linter changes #4
mmalczak 3869ce2
Adjust for PyQt6 #4
mmalczak 4a4ffb5
Do not accommodate state migration between releases #4
mmalczak b4a372f
Remove unnecessary 'isinstance' checks #4
mmalczak aa71de0
Remove duplicated code #4
mmalczak 24e51e3
PyQt6 adjustments #4
mmalczak 59fc695
Save mdi area state using indexes from the centralWidget. Remove get_…
mmalczak fbf8ec3
Do not enforce unique tab names #4
mmalczak 8951907
Hide the close button when there is only one workspace left #4
mmalczak 5354921
Rename the first Workspace instead of deleteing it when restoring the…
mmalczak a2dfde1
Spawn a QLineEdit and positioning it over the tab text instead of usi…
mmalczak 507ed75
Remove docstrings or replace it with standard comments #4
mmalczak cc16f7e
Move EditableMdiTabBar to artiq/gui/tools.py #4
mmalczak deb8f4d
Encapsulate the 'multiple panes' logic in MultipleTabsManagement clas…
mmalczak bf3ed36
Make the tabs movable #4
mmalczak File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -61,6 +61,42 @@ 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): | ||
|
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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I did it and pushed the changes. 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) | ||
|
|
@@ -74,19 +110,128 @@ def __init__(self, server): | |
|
|
||
| self.exit_request = asyncio.Event() | ||
|
|
||
| self.tab_widget = QtWidgets.QTabWidget() | ||
|
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 | ||
|
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. | ||
| """ | ||
|
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") | ||
|
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(): | ||
|
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): | ||
|
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): | ||
|
|
@@ -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) | ||
|
|
@@ -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 | ||
|
|
@@ -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, | ||
|
|
@@ -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, | ||
|
|
||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.