feat(dock_area): browser-style workspace tabs with hover close and inline new-tab buttons

This commit is contained in:
Claude committed 2026-10-09 22:03:57 +00:00
1 parent 1a65152554
commit e278d0f721
2 files changed
+281 -16

No files matched your search

@@ -6,17 +6,21 @@ from typing import Any
from bec_lib import bec_logger
from bec_qthemes import material_icon
from qtpy.QtCore import QPoint, Qt, Signal
from qtpy.QtCore import QPoint, QSize, Qt, Signal
from qtpy.QtGui import QColor, QIcon, QPainter, QPixmap
from qtpy.QtWidgets import (
QApplication,
QInputDialog,
QMenu,
QMessageBox,
QTabBar,
QTabWidget,
QToolButton,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.colors import rgba, theme_color
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea, StartupProfile
from bec_widgets.widgets.containers.dock_area.profile_utils import (
@@ -31,6 +35,147 @@ logger = bec_logger.logger
UNTITLED_WORKSPACE = "Untitled"
_CLOSE_ICON_SIZE = QSize(14, 14)
_TAB_BUTTON_SIZE = QSize(22, 22)
_TAB_BUTTON_GAP = 4
def _dot_icon(color: QColor, diameter: int = 8) -> QIcon:
"""Return a small filled circle used to mark unsaved workspaces."""
pixmap = QPixmap(diameter, diameter)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(color)
painter.drawEllipse(0, 0, diameter, diameter)
painter.end()
return QIcon(pixmap)
class WorkspaceTabBar(QTabBar):
"""
Browser-style tab bar: rounded tabs, a close button that appears on hover or on the
current tab, and the new-workspace buttons placed right after the last tab.
"""
close_requested = Signal(int)
def __init__(self, parent: QWidget | None = None):
# Set before any Qt call, since setters can already invoke the overrides below
self._hover_index = -1
self._trailing: list[QWidget] = []
super().__init__(parent)
self.setObjectName("WorkspaceTabBar")
self.setMouseTracking(True)
self.setDrawBase(False)
self.setExpanding(False)
self.setElideMode(Qt.TextElideMode.ElideRight)
self.setUsesScrollButtons(True)
self.currentChanged.connect(lambda _index: self._update_close_buttons())
self.tabMoved.connect(lambda *_: self._update_close_buttons())
def add_trailing_widget(self, widget: QWidget) -> None:
"""Place *widget* after the last tab; it moves along as tabs are added or closed."""
widget.setParent(self)
self._trailing.append(widget)
self._position_trailing()
self.updateGeometry()
def _trailing_width(self) -> int:
return sum(w.sizeHint().width() + _TAB_BUTTON_GAP for w in self._trailing)
def sizeHint(self) -> QSize: # pylint: disable=invalid-name
"""Reserve room for the trailing buttons."""
hint = super().sizeHint()
return QSize(hint.width() + self._trailing_width(), hint.height())
def minimumSizeHint(self) -> QSize: # pylint: disable=invalid-name
"""Reserve room for the trailing buttons."""
hint = super().minimumSizeHint()
return QSize(hint.width() + self._trailing_width(), hint.height())
def _position_trailing(self) -> None:
if not self._trailing:
return
if self.count():
x = self.tabRect(self.count() - 1).right() + _TAB_BUTTON_GAP
else:
x = _TAB_BUTTON_GAP
x = min(x, max(0, self.width() - self._trailing_width()))
for widget in self._trailing:
size = widget.sizeHint()
widget.setGeometry(x, (self.height() - size.height()) // 2, size.width(), size.height())
widget.raise_()
x += size.width() + _TAB_BUTTON_GAP
def tabInserted(self, index: int) -> None: # pylint: disable=invalid-name
"""Give each new tab its own close button."""
super().tabInserted(index)
button = QToolButton(self)
button.setObjectName("WorkspaceTabCloseButton")
button.setAutoRaise(True)
button.setFixedSize(_TAB_BUTTON_SIZE)
button.setIconSize(_CLOSE_ICON_SIZE)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setToolTip("Close workspace")
button.clicked.connect(lambda _checked=False, b=button: self._on_close_clicked(b))
self.setTabButton(index, QTabBar.ButtonPosition.RightSide, button)
self._update_close_buttons()
def tabRemoved(self, index: int) -> None: # pylint: disable=invalid-name
"""Refresh close buttons after a tab is removed."""
super().tabRemoved(index)
self._hover_index = -1
self._update_close_buttons()
def tabLayoutChange(self) -> None: # pylint: disable=invalid-name
"""Keep the trailing buttons next to the last tab."""
super().tabLayoutChange()
self._position_trailing()
def resizeEvent(self, event) -> None: # pylint: disable=invalid-name
"""Keep the trailing buttons next to the last tab."""
super().resizeEvent(event)
self._position_trailing()
def mouseMoveEvent(self, event) -> None: # pylint: disable=invalid-name
"""Track the hovered tab to show its close button."""
super().mouseMoveEvent(event)
self._set_hover_index(self.tabAt(event.position().toPoint()))
def leaveEvent(self, event) -> None: # pylint: disable=invalid-name
"""Hide the hover close button when the mouse leaves."""
super().leaveEvent(event)
self._set_hover_index(-1)
def _set_hover_index(self, index: int) -> None:
if index != self._hover_index:
self._hover_index = index
self._update_close_buttons()
def _on_close_clicked(self, button: QToolButton) -> None:
for index in range(self.count()):
if self.tabButton(index, QTabBar.ButtonPosition.RightSide) is button:
self.close_requested.emit(index)
return
def close_button_visible(self, index: int) -> bool:
"""Whether the close button of tab *index* is currently shown."""
button = self.tabButton(index, QTabBar.ButtonPosition.RightSide)
return button is not None and not button.icon().isNull()
def _update_close_buttons(self) -> None:
icon = material_icon("close", size=_CLOSE_ICON_SIZE)
for index in range(self.count()):
button = self.tabButton(index, QTabBar.ButtonPosition.RightSide)
if button is None:
continue
# Swap the icon rather than hiding the button so tab widths stay stable.
show = index in (self.currentIndex(), self._hover_index)
button.setIcon(icon if show else QIcon())
button.setEnabled(show)
class WorkspacePage(QWidget):
"""
@@ -88,7 +233,7 @@ class WorkspacePage(QWidget):
return self.dock_area
class WorkspaceTabs(QTabWidget):
class WorkspaceTabs(QTabWidget): # pylint: disable=too-many-instance-attributes
"""
Tab widget hosting one :class:`BECDockArea` per workspace.
@@ -125,31 +270,42 @@ class WorkspaceTabs(QTabWidget):
self._closing = False
self._restoring = False
self.setTabBar(WorkspaceTabBar(self))
self.setDocumentMode(True)
self.setMovable(True)
self.setTabsClosable(True)
self.setElideMode(Qt.TextElideMode.ElideRight)
# Size tabs to their titles; the app theme stretches tab bars to full width by default.
self.tabBar().setObjectName("WorkspaceTabBar")
self.setStyleSheet("QTabBar#WorkspaceTabBar { qproperty-expanding: false; }")
self.tabCloseRequested.connect(self.close_workspace)
self.tabBar().close_requested.connect(self.close_workspace)
self.currentChanged.connect(self._on_current_changed)
self.tabBar().tabMoved.connect(lambda *_: self._persist())
self.tabBar().tabBarDoubleClicked.connect(self._on_tab_double_clicked)
self.tabBar().setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.tabBar().customContextMenuRequested.connect(self._show_tab_menu)
self.new_workspace_button = QToolButton(self)
self.new_workspace_button = QToolButton()
self.new_workspace_button.setObjectName("NewWorkspaceButton")
self.new_workspace_button.setIcon(material_icon("add"))
self.new_workspace_button.setAutoRaise(True)
self.new_workspace_button.setToolTip("New workspace (click) or open a saved one (arrow)")
self.new_workspace_button.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup)
self._open_menu = QMenu(self.new_workspace_button)
self._open_menu.aboutToShow.connect(self._populate_open_menu)
self.new_workspace_button.setMenu(self._open_menu)
self.new_workspace_button.setFixedSize(_TAB_BUTTON_SIZE)
self.new_workspace_button.setToolTip("New workspace")
self.new_workspace_button.clicked.connect(self.new_workspace)
self.setCornerWidget(self.new_workspace_button, Qt.Corner.TopRightCorner)
self.open_workspace_button = QToolButton()
self.open_workspace_button.setObjectName("OpenWorkspaceButton")
self.open_workspace_button.setIcon(material_icon("expand_more"))
self.open_workspace_button.setAutoRaise(True)
self.open_workspace_button.setFixedSize(_TAB_BUTTON_SIZE)
self.open_workspace_button.setToolTip("Open a saved workspace")
self.open_workspace_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
self._open_menu = QMenu(self.open_workspace_button)
self._open_menu.aboutToShow.connect(self._populate_open_menu)
self.open_workspace_button.setMenu(self._open_menu)
self.tabBar().add_trailing_widget(self.new_workspace_button)
self.tabBar().add_trailing_widget(self.open_workspace_button)
self._apply_style()
app = QApplication.instance()
if app is not None and hasattr(app, "theme"):
app.theme.theme_changed.connect(self._apply_style)
if restore:
self._restore_tabs()
@@ -210,7 +366,7 @@ class WorkspaceTabs(QTabWidget):
"""
page = WorkspacePage(self, profile=profile, startup_profile=startup_profile)
index = self.addTab(page, self._tab_title(profile))
self.setTabToolTip(index, self._tab_tooltip(profile))
self._sync_tab(page)
if activate:
self.setCurrentIndex(index)
self._materialize(page)
@@ -322,6 +478,12 @@ class WorkspaceTabs(QTabWidget):
"""Persist the open tabs and close every dock area (each autosaves its profile)."""
self._persist()
self._closing = True
app = QApplication.instance()
if app is not None and hasattr(app, "theme"):
try:
app.theme.theme_changed.disconnect(self._apply_style)
except (RuntimeError, TypeError):
pass
for page in self.pages():
if page.dock_area is not None:
page.dock_area.close()
@@ -331,6 +493,68 @@ class WorkspaceTabs(QTabWidget):
# Internals
################################################################################
@SafeSlot()
@SafeSlot(str)
def _apply_style(self, _theme: str | None = None) -> None:
"""Style the tabs as rounded pills using the current BEC theme colours."""
app = QApplication.instance()
theme = getattr(app, "theme", None)
palette = self.palette()
fg = theme_color(theme, "FG", palette.windowText().color())
card = theme_color(theme, "CARD_BG", palette.base().color())
border = theme_color(theme, "BORDER", palette.mid().color())
primary = theme_color(theme, "PRIMARY", palette.highlight().color())
self._unsaved_icon = _dot_icon(primary)
self.setStyleSheet(f"""
QTabBar#WorkspaceTabBar {{
qproperty-expanding: false;
background: transparent;
border: none;
padding: 0px;
margin: 0px;
}}
QTabBar#WorkspaceTabBar::tab {{
background: transparent;
color: {rgba(fg, 170)};
border: 1px solid transparent;
border-radius: 8px;
padding: 4px 4px 4px 12px;
margin: 6px 2px 4px 2px;
min-width: 72px;
max-width: 220px;
}}
QTabBar#WorkspaceTabBar::tab:first {{
margin-left: 6px;
}}
QTabBar#WorkspaceTabBar::tab:hover {{
background: {rgba(fg, 18)};
color: {rgba(fg, 230)};
}}
QTabBar#WorkspaceTabBar::tab:selected {{
background: {card.name()};
border: 1px solid {border.name()};
color: {fg.name()};
}}
QToolButton#WorkspaceTabCloseButton,
QToolButton#NewWorkspaceButton,
QToolButton#OpenWorkspaceButton {{
border: none;
border-radius: 6px;
background: transparent;
padding: 0px;
}}
QToolButton#WorkspaceTabCloseButton:hover,
QToolButton#NewWorkspaceButton:hover,
QToolButton#OpenWorkspaceButton:hover {{
background: {rgba(fg, 30)};
}}
QToolButton#OpenWorkspaceButton::menu-indicator {{
image: none;
}}
""")
for page in self.pages():
self._sync_tab(page)
def _restore_tabs(self) -> None:
names, current = get_open_workspaces(namespace=self.profile_namespace)
known = set(list_profiles(self.profile_namespace)) if names else set()
@@ -401,6 +625,7 @@ class WorkspaceTabs(QTabWidget):
return
self.setTabText(index, self._tab_title(page.profile))
self.setTabToolTip(index, self._tab_tooltip(page.profile))
self.setTabIcon(index, self._unsaved_icon if page.profile is None else QIcon())
@staticmethod
def _tab_title(profile: str | None) -> str:
+40
View File
@@ -267,3 +267,43 @@ def test_open_menu_lists_saved_profiles(make_tabs):
texts = [action.text() for action in tabs._open_menu.actions()]
assert "New Workspace" in texts
assert "alignment" in texts
def test_close_button_shows_on_current_tab_only(make_tabs):
tabs = make_tabs()
tabs.new_workspace()
bar = tabs.tabBar()
assert bar.close_button_visible(1)
assert not bar.close_button_visible(0)
tabs.setCurrentIndex(0)
assert bar.close_button_visible(0)
assert not bar.close_button_visible(1)
def test_close_button_closes_its_tab(make_tabs):
tabs = make_tabs()
_save_profile(tabs, "alignment")
tabs.new_workspace()
_save_profile(tabs, "scans")
button = tabs.tabBar().tabButton(1, tabs.tabBar().ButtonPosition.RightSide)
button.click()
assert tabs.workspace_names() == ["alignment"]
def test_new_workspace_button_follows_last_tab(make_tabs, qtbot):
tabs = make_tabs()
bar = tabs.tabBar()
qtbot.wait(10)
first_x = tabs.new_workspace_button.x()
assert first_x >= bar.tabRect(0).right()
tabs.new_workspace_button.click()
qtbot.wait(10)
assert tabs.count() == 2
assert tabs.new_workspace_button.x() > first_x
def test_unsaved_tab_has_dot_icon(make_tabs):
tabs = make_tabs()
assert not tabs.tabIcon(0).isNull()
_save_profile(tabs, "alignment")
assert tabs.tabIcon(0).isNull()