feat(dock_area): profile switcher with saved previews in place of the toolbar profile list, docks-only previews (QWidget and QML)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpeYDYWxwjcsqJt2BwhDNR
This commit is contained in:
Claude committed 2026-10-09 23:08:23 +00:00
1 parent 8d6d276928
commit 4b2a7aadb3
9 files changed
+1090 -15

No files matched your search

@@ -5,7 +5,8 @@
* human dock titles (``Motor``, ``Waveform 2``) that can be renamed by double-clicking the tab,
* tab-only close with an Undo bar instead of deleting a whole group at once,
* one Add widget gallery with search and a placement choice (Ctrl+Shift+A),
* an empty state with starter layouts.
* an empty state with starter layouts,
* a profile switcher with previews in place of the toolbar profile list.
The gallery and the empty state come in a QWidget and a QML version; ``BEC_DOCK_CHROME`` picks
one (see :func:`~.ads_style.chrome_mode`).
@@ -42,7 +43,7 @@ if TYPE_CHECKING: # pragma: no cover
__all__ = ["DockChrome", "chrome_mode", "configure_ads_flags", "apply_dock_chrome_style"]
class DockChrome(QObject):
class DockChrome(QObject): # pylint: disable=too-many-instance-attributes
"""Owns the gallery, empty state, undo bar and tab renaming of one dock area.
Args:
@@ -63,6 +64,8 @@ class DockChrome(QObject):
self._batch_open = False
self._last_dock: CDockWidget | None = None
self.gallery = None
self.switcher = None
self.switcher_controller = None
self.undo_bar = UndoBar(area)
self.undo_bar.undo_requested.connect(self.undo_close)
@@ -254,6 +257,69 @@ class DockChrome(QObject):
return dock
return None
# ------------------------------------------------------------------ profile switcher
def attach_profile_switcher(self, combo) -> None:
"""Make the toolbar profile combo open the profile switcher instead of a plain list."""
from bec_widgets.widgets.containers.dock_area.chrome.profile_switcher_qwidget import (
paint_profile_button,
)
combo.setMinimumWidth(200)
combo.setFixedHeight(30)
combo.setToolTip("Switch profile")
combo.setAttribute(Qt.WidgetAttribute.WA_Hover, True)
combo.set_switcher(self.open_profile_switcher, paint_profile_button)
def open_profile_switcher(self, anchor: QWidget | None = None) -> None:
"""Open the profile switcher below ``anchor``."""
if self.switcher is None:
from bec_widgets.widgets.containers.dock_area.chrome.profile_switcher import (
ProfileSwitcherController,
)
from bec_widgets.widgets.containers.dock_area.profile_ux.common import ProfileActions
publish = None
if self.mode == "qml":
from bec_widgets.widgets.containers.dock_area.profile_ux.profile_qml import (
preview_provider,
)
publish = preview_provider().publish
self.switcher_controller = ProfileSwitcherController(
ProfileActions(self.area), publish, self
)
self.switcher_controller.profile_chosen.connect(self._switch_profile)
self.switcher_controller.action_requested.connect(self._run_profile_action)
if self.mode == "qml":
from bec_widgets.widgets.containers.dock_area.chrome.chrome_qml import (
ProfileSwitcherQmlPopup,
)
self.switcher = ProfileSwitcherQmlPopup(self.switcher_controller, self.area)
else:
from bec_widgets.widgets.containers.dock_area.chrome import profile_switcher_qwidget
self.switcher = profile_switcher_qwidget.ProfileSwitcherPopup(
self.switcher_controller, self.area
)
self.switcher.open_at(anchor)
def _switch_profile(self, name: str, new_tab: bool) -> None:
actions = self.switcher_controller.actions
if actions.host is not None and (new_tab or actions.open_elsewhere(name)):
actions.open(name, new_tab=True)
return
if name != actions.current_profile():
self.area.load_profile(name)
def _run_profile_action(self, key: str) -> None:
if key == "save":
self.area.save_profile_dialog()
elif key == "revert":
self.area.restore_baseline_profile(show_dialog=True)
elif key == "library":
self.area.show_workspace_manager()
# ------------------------------------------------------------------ empty state
def _on_dock_added(self, dock: CDockWidget) -> None:
self._last_dock = dock
@@ -293,16 +359,19 @@ class DockChrome(QObject):
apply_dock_chrome_style(self.area.dock_manager, True)
self.undo_bar.refresh_theme()
self.empty_state.refresh_theme()
if self.gallery is not None:
self.gallery.refresh_theme()
for popup in (self.gallery, self.switcher):
if popup is not None:
popup.refresh_theme()
def cleanup(self) -> None:
"""Delete pending docks and unload QML scenes."""
self.flush()
for view in (self.gallery, self.empty_state):
for view in (self.gallery, self.switcher, self.empty_state):
if view is not None and hasattr(view, "cleanup"):
view.cleanup()
if self.gallery is not None:
self.gallery.close()
self.gallery.deleteLater()
self.gallery = None
for popup in (self.gallery, self.switcher):
if popup is not None:
popup.close()
popup.deleteLater()
self.gallery = None
self.switcher = None
@@ -101,3 +101,57 @@ class EmptyStateQml(QWidget):
def cleanup(self) -> None:
"""Unload the scene before the controller goes away."""
release_quick_widget(self.view)
class ProfileSwitcherQmlPopup(QFrame):
"""The profile switcher as a popup rendered with Qt Quick.
Args:
controller(ProfileSwitcherController): Shared switcher state.
parent(QWidget | None): Parent widget.
"""
def __init__(self, controller, parent: QWidget | None = None):
super().__init__(parent, Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint)
self.setObjectName("profileSwitcherQml")
self.controller = controller
self.resize(420, 460)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.view = create_quick_widget(
self, QML_DIR / "ProfileSwitcher.qml", {"backend": controller}
)
self.view.setClearColor(ThemeTokens().card)
layout.addWidget(self.view)
root = self.view.rootObject()
if root is not None:
root.closeRequested.connect(self.close)
controller.profile_chosen.connect(lambda *_: self.close())
def open_at(self, anchor: QWidget | None) -> None:
"""Show the switcher below ``anchor``, right-aligned with it."""
self.controller.reset()
if anchor is not None:
pos = anchor.mapToGlobal(QPoint(anchor.width() - self.width(), anchor.height() + 4))
else:
parent = self.parentWidget()
center = parent.mapToGlobal(parent.rect().center()) if parent else QPoint(200, 200)
pos = center - QPoint(self.width() // 2, self.height() // 2)
screen = self.screen().availableGeometry() if self.screen() else None
if screen is not None:
pos.setX(max(screen.left(), min(pos.x(), screen.right() - self.width())))
pos.setY(max(screen.top(), min(pos.y(), screen.bottom() - self.height())))
self.move(pos)
self.show()
self.view.setFocus(Qt.FocusReason.PopupFocusReason)
root = self.view.rootObject()
if root is not None:
root.focusSearch()
def refresh_theme(self) -> None:
"""The QML scene follows ``theme`` by itself; only the clear colour is updated."""
self.view.setClearColor(ThemeTokens().card)
def cleanup(self) -> None:
"""Unload the scene before the controller goes away."""
release_quick_widget(self.view)
@@ -0,0 +1,246 @@
"""State shared by the QWidget and QML versions of the profile switcher.
The switcher replaces the plain drop-down list of the toolbar profile combo. It shows every
profile with its saved preview, says which one is open (here or in another tab), filters as you
type, and offers Save as, Revert and the profile library at the bottom.
"""
from __future__ import annotations
import os
from qtpy.QtCore import Property, QObject, QSize, Qt, Signal, Slot
from qtpy.QtGui import QPixmap
from bec_widgets.utils.quick.list_model import DictListModel
from bec_widgets.widgets.containers.dock_area.profile_utils import (
get_profile_info,
list_profiles,
load_profile_screenshot,
)
from bec_widgets.widgets.containers.dock_area.profile_ux.common import (
ProfileActions,
relative_time,
widget_count_label,
)
ROW_ROLES = (
"name",
"subtitle",
"section",
"previewUrl",
"readOnly",
"pinned",
"isCurrent",
"isOpenElsewhere",
"header",
)
SECTION_PINNED = "In the toolbar list"
SECTION_OTHER = "Other profiles"
THUMB_SIZE = QSize(96, 54)
# Footer actions: key, label, icon, tooltip
FOOTER_ACTIONS = (
("save", "Save as…", "save", "Save the current layout as a profile"),
("revert", "Revert…", "history", "Go back to the saved version of this profile"),
("library", "All profiles…", "folder_open", "Open the profile library"),
)
def thumbnail(pixmap: QPixmap | None, size: QSize = THUMB_SIZE) -> QPixmap | None:
"""Scale a stored preview down to a list thumbnail, or None when there is none."""
if pixmap is None or pixmap.isNull():
return None
return pixmap.scaled(
size,
Qt.AspectRatioMode.KeepAspectRatioByExpanding,
Qt.TransformationMode.SmoothTransformation,
).copy(0, 0, size.width(), size.height())
class ProfileSwitcherController(QObject): # pylint: disable=too-many-instance-attributes
"""Rows, search and highlight of the profile switcher.
Args:
actions(ProfileActions): Profile operations of the dock area the switcher belongs to.
publish_preview(callable | None): Turns a thumbnail into a URL for QML; the QWidget view
reads :meth:`thumbnail_for` instead.
parent(QObject | None): Parent object.
"""
# name, open in a new tab
profile_chosen = Signal(str, bool)
# one of the FOOTER_ACTIONS keys
action_requested = Signal(str)
rows_changed = Signal()
query_changed = Signal()
highlight_changed = Signal()
def __init__(self, actions: ProfileActions, publish_preview=None, parent=None):
super().__init__(parent)
self.actions = actions
self._publish = publish_preview
self._query = ""
self._highlight = 0
self._entries: list[dict] = []
self._thumbs: dict[str, QPixmap | None] = {}
# name -> (file stamps, thumbnail); previews are only decoded again when a file changed
self._thumb_cache: dict[str, tuple[tuple, QPixmap | None]] = {}
self.rows = DictListModel(ROW_ROLES, self)
# ------------------------------------------------------------------ building
def refresh(self) -> None:
"""Re-read profiles and previews from disk."""
namespace = self.actions.namespace
current = self.actions.current_profile()
open_names = set(self.actions.open_profiles())
entries = []
self._thumbs = {}
for name in list_profiles(namespace):
info = get_profile_info(name, namespace)
count = info.widget_count
if name == current:
count = len(self.actions.dock_area().dock_list())
subtitle = [widget_count_label(count)]
edited = relative_time(info.modified)
if edited:
subtitle.append(f"edited {edited}")
if info.notes:
subtitle = [info.notes.splitlines()[0]]
thumb = self._cached_thumbnail(name, info)
self._thumbs[name] = thumb
entries.append(
{
"name": name,
"subtitle": " · ".join(subtitle),
"section": SECTION_PINNED if info.is_quick_select else SECTION_OTHER,
"previewUrl": self._publish(f"switcher-{name}", thumb) if self._publish else "",
"readOnly": info.is_read_only,
"pinned": info.is_quick_select,
"isCurrent": name == current,
"isOpenElsewhere": name != current and name in open_names,
}
)
order = {SECTION_PINNED: 0, SECTION_OTHER: 1}
entries.sort(key=lambda e: (order[e["section"]], e["name"].lower()))
self._entries = entries
self._rebuild(keep_current=True)
def _cached_thumbnail(self, name: str, info) -> QPixmap | None:
stamps = tuple(
os.path.getmtime(path) if path and os.path.exists(path) else 0.0
for path in (info.runtime_path, info.baseline_path)
)
cached = self._thumb_cache.get(name)
if cached is not None and cached[0] == stamps:
return cached[1]
thumb = thumbnail(load_profile_screenshot(name, namespace=self.actions.namespace))
self._thumb_cache[name] = (stamps, thumb)
return thumb
def _matches(self, entry: dict) -> bool:
query = self._query.strip().lower()
if not query:
return True
haystack = f"{entry['name']} {entry['subtitle']}".lower()
return all(part in haystack for part in query.split())
def _rebuild(self, keep_current: bool = False) -> None:
rows = [dict(entry) for entry in self._entries if self._matches(entry)]
sections = {row["section"] for row in rows}
previous = None
for row in rows:
# Section title on the first row of each section, when there is more than one
row["header"] = (
row["section"] if len(sections) > 1 and row["section"] != previous else ""
)
previous = row["section"]
self.rows.set_items(rows)
highlight = 0
if keep_current:
highlight = next((i for i, row in enumerate(rows) if row["isCurrent"]), 0)
self.set_highlight(highlight)
self.rows_changed.emit()
def thumbnail_for(self, name: str) -> QPixmap | None:
"""List thumbnail of *name*, or None when it has no saved preview."""
return self._thumbs.get(name)
# ------------------------------------------------------------------ properties
def _get_query(self) -> str:
return self._query
@Slot(str)
def set_query(self, text: str) -> None:
"""Filter the rows by name and description."""
if text == self._query:
return
self._query = text
self.query_changed.emit()
self._rebuild()
query = Property(str, _get_query, set_query, notify=query_changed)
def _get_highlight(self) -> int:
return self._highlight
@Slot(int)
def set_highlight(self, row: int) -> None:
"""Highlight *row* (clamped to the visible rows)."""
row = max(0, min(row, self.rows.count - 1)) if self.rows.count else -1
if row != self._highlight:
self._highlight = row
self.highlight_changed.emit()
highlight = Property(int, _get_highlight, set_highlight, notify=highlight_changed)
@Slot(int)
def move_highlight(self, delta: int) -> None:
"""Move the highlight up or down, wrapping around."""
if self.rows.count:
self.set_highlight((self._highlight + delta) % self.rows.count)
def _current_name(self) -> str:
return self.actions.current_profile() or ""
def _can_revert(self) -> bool:
name = self._current_name()
return bool(name) and self.actions.can_revert(name)
def _empty_text(self) -> str:
query = self._query.strip()
if query:
return f"No profile matches '{query}'."
return "No saved profiles yet. Use Save as… to keep this layout."
def _has_tabs(self) -> bool:
return self.actions.host is not None
currentName = Property(str, _current_name, notify=rows_changed)
hasTabs = Property(bool, _has_tabs, constant=True)
canRevert = Property(bool, _can_revert, notify=rows_changed)
emptyText = Property(str, _empty_text, notify=rows_changed)
# ------------------------------------------------------------------ actions
@Slot()
@Slot(int)
@Slot(int, bool)
def activate(self, row: int = -1, new_tab: bool = False) -> None:
"""Open the highlighted (or given) profile."""
row = self._highlight if row < 0 else row
items = self.rows.items
if not 0 <= row < len(items):
return
self.profile_chosen.emit(items[row]["name"], bool(new_tab))
@Slot(str)
def request(self, key: str) -> None:
"""Run a footer action: ``save``, ``revert`` or ``library``."""
self.action_requested.emit(key)
def reset(self) -> None:
"""Clear the search and reload, highlighting the open profile."""
self._query = ""
self.query_changed.emit()
self.refresh()
rowModel = Property(QObject, lambda self: self.rows, constant=True)
@@ -0,0 +1,378 @@
"""QWidget version of the profile switcher popup."""
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import QEvent, QModelIndex, QPoint, QRect, QSize, Qt
from qtpy.QtGui import QFont, QPainter, QPainterPath, QPen
from qtpy.QtWidgets import (
QAbstractItemView,
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QStyle,
QStyledItemDelegate,
QStyleOptionViewItem,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.quick.host import ThemeTokens
from bec_widgets.utils.ux_kit import TextButton, field_qss
from bec_widgets.widgets.containers.dock_area.chrome.profile_switcher import (
FOOTER_ACTIONS,
THUMB_SIZE,
ProfileSwitcherController,
)
_KIND = Qt.ItemDataRole.UserRole + 1
_ROW = Qt.ItemDataRole.UserRole + 2
_DATA = Qt.ItemDataRole.UserRole + 3
class _ProfileDelegate(QStyledItemDelegate):
"""Paints section headers and profile rows (thumbnail, name, badges, subtitle)."""
def __init__(self, controller: ProfileSwitcherController, parent=None):
super().__init__(parent)
self.controller = controller
self.tokens = ThemeTokens()
self.highlight_row = -1
def sizeHint(self, option, index): # pylint: disable=invalid-name
"""Headers are short, profile rows fit the thumbnail."""
if index.data(_KIND) == "header":
return QSize(option.rect.width(), 30)
return QSize(option.rect.width(), THUMB_SIZE.height() + 14)
def _badge(self, painter: QPainter, right: int, top: int, text: str, color) -> int:
font = QFont(painter.font())
font.setPixelSize(10)
font.setWeight(QFont.Weight.DemiBold)
painter.setFont(font)
width = painter.fontMetrics().horizontalAdvance(text) + 12
rect = QRect(right - width, top, width, 17)
path = QPainterPath()
path.addRoundedRect(rect, 8, 8)
painter.fillPath(path, self.tokens.soft(color, 0.18))
painter.setPen(color)
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, text)
return rect.left() - 6
def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex):
"""Paint a section header or a profile row."""
tokens = self.tokens
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = option.rect
if index.data(_KIND) == "header":
font = QFont(option.font)
font.setPixelSize(11)
font.setWeight(QFont.Weight.DemiBold)
painter.setFont(font)
painter.setPen(tokens.fg_subtle)
painter.drawText(
rect.adjusted(12, 8, -12, 0),
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
str(index.data(Qt.ItemDataRole.DisplayRole)).upper(),
)
painter.restore()
return
data = index.data(_DATA) or {}
highlighted = index.data(_ROW) == self.highlight_row
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
card = rect.adjusted(4, 1, -4, -1)
if highlighted or hovered:
path = QPainterPath()
path.addRoundedRect(card, 7, 7)
painter.fillPath(
path, tokens.soft(tokens.primary, 0.14) if highlighted else tokens.hover
)
if highlighted:
painter.setPen(QPen(tokens.soft(tokens.primary, 0.5), 1))
painter.drawPath(path)
thumb_rect = QRect(
card.left() + 6, card.center().y() - THUMB_SIZE.height() // 2 + 1, *THUMB_SIZE.toTuple()
)
clip = QPainterPath()
clip.addRoundedRect(thumb_rect, 5, 5)
thumb = self.controller.thumbnail_for(data.get("name", ""))
if thumb is not None:
painter.save()
painter.setClipPath(clip)
painter.drawPixmap(thumb_rect, thumb)
painter.restore()
else:
painter.fillPath(clip, tokens.soft(tokens.fg, 0.05))
icon = material_icon(
"dashboard", size=(40, 40), color=tokens.fg_subtle, convert_to_pixmap=False
)
icon.paint(painter, thumb_rect.adjusted(36, 15, -36, -15))
painter.setPen(QPen(tokens.border, 1))
painter.drawPath(clip)
text_left = thumb_rect.right() + 12
right = card.right() - 10
badge_top = card.top() + 9
if data.get("isCurrent"):
right = self._badge(painter, right, badge_top, "Open here", tokens.primary)
elif data.get("isOpenElsewhere"):
right = self._badge(painter, right, badge_top, "In another tab", tokens.fg_muted)
if data.get("readOnly"):
right = self._badge(painter, right, badge_top, "Read-only", tokens.fg_muted)
name_font = QFont(option.font)
name_font.setPixelSize(13)
name_font.setWeight(QFont.Weight.DemiBold)
painter.setFont(name_font)
painter.setPen(tokens.fg)
name_rect = QRect(text_left, card.top() + 8, right - text_left, 20)
name = painter.fontMetrics().elidedText(
data.get("name", ""), Qt.TextElideMode.ElideRight, name_rect.width()
)
painter.drawText(
name_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, name
)
sub_font = QFont(option.font)
sub_font.setPixelSize(12)
painter.setFont(sub_font)
painter.setPen(tokens.fg_muted)
sub_rect = QRect(text_left, card.top() + 30, card.right() - text_left - 10, 18)
subtitle = painter.fontMetrics().elidedText(
data.get("subtitle", ""), Qt.TextElideMode.ElideRight, sub_rect.width()
)
painter.drawText(sub_rect, Qt.AlignmentFlag.AlignLeft, subtitle)
painter.restore()
class ProfileSwitcherPopup(QFrame):
"""The profile switcher as a popup below the toolbar profile button.
Args:
controller(ProfileSwitcherController): Shared switcher state.
parent(QWidget | None): Parent widget.
"""
def __init__(self, controller: ProfileSwitcherController, parent: QWidget | None = None):
super().__init__(parent, Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint)
self.setObjectName("profileSwitcher")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.controller = controller
self.resize(420, 460)
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 10)
layout.setSpacing(8)
self.search = QLineEdit(self)
self.search.setPlaceholderText("Find a profile")
self.search.setClearButtonEnabled(True)
self.search.textChanged.connect(controller.set_query)
self.search.installEventFilter(self)
layout.addWidget(self.search)
self.list = QListWidget(self)
self.list.setObjectName("switcherList")
self.list.setMouseTracking(True)
self.list.setFrameShape(QFrame.Shape.NoFrame)
self.list.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.list.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.list.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.delegate = _ProfileDelegate(controller, self.list)
self.list.setItemDelegate(self.delegate)
self.list.itemClicked.connect(self._on_item_clicked)
self.list.entered.connect(self._on_item_entered)
layout.addWidget(self.list, 1)
self.empty_label = QLabel(self)
self.empty_label.setObjectName("switcherMuted")
self.empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.empty_label.setWordWrap(True)
layout.addWidget(self.empty_label)
separator = QFrame(self)
separator.setObjectName("switcherSeparator")
separator.setFixedHeight(1)
layout.addWidget(separator)
footer = QHBoxLayout()
footer.setSpacing(4)
self.footer_buttons: dict[str, TextButton] = {}
for key, label, icon, tooltip in FOOTER_ACTIONS:
button = TextButton(label, "ghost", icon, self)
button.setToolTip(tooltip)
button.clicked.connect(lambda _=False, k=key: self._run(k))
self.footer_buttons[key] = button
footer.addWidget(button)
footer.addStretch(1)
layout.addLayout(footer)
self.hint = QLabel(self)
self.hint.setObjectName("switcherFooter")
layout.addWidget(self.hint)
controller.rows_changed.connect(self._rebuild)
controller.highlight_changed.connect(self._sync_highlight)
controller.profile_chosen.connect(lambda *_: self.close())
self.refresh_theme()
# ------------------------------------------------------------------ build
def _rebuild(self, *_args) -> None:
self.list.clear()
last_section = None
rows = self.controller.rows.items
show_headers = len({row["section"] for row in rows}) > 1
for row, item in enumerate(rows):
if show_headers and item["section"] != last_section:
header = QListWidgetItem(item["section"])
header.setData(_KIND, "header")
header.setFlags(Qt.ItemFlag.NoItemFlags)
self.list.addItem(header)
last_section = item["section"]
entry = QListWidgetItem(item["name"])
entry.setData(_KIND, "item")
entry.setData(_ROW, row)
entry.setData(_DATA, item)
entry.setToolTip(item["subtitle"])
self.list.addItem(entry)
self.empty_label.setText(self.controller.emptyText)
self.empty_label.setVisible(not rows)
self.footer_buttons["revert"].setEnabled(self.controller.canRevert)
hint = "↑ ↓ choose · Enter open"
if self.controller.hasTabs:
hint += " · Ctrl+Enter or Ctrl+click new tab"
self.hint.setText(hint + " · Esc close")
self._sync_highlight()
def _sync_highlight(self) -> None:
self.delegate.highlight_row = self.controller.highlight
for i in range(self.list.count()):
if self.list.item(i).data(_ROW) == self.controller.highlight:
self.list.scrollToItem(self.list.item(i))
break
self.list.viewport().update()
# ------------------------------------------------------------------ interaction
def _run(self, key: str) -> None:
self.close()
self.controller.request(key)
def _on_item_clicked(self, item: QListWidgetItem) -> None:
if item.data(_KIND) == "item":
ctrl = QApplication.keyboardModifiers() & Qt.KeyboardModifier.ControlModifier
new_tab = bool(ctrl) and self.controller.hasTabs
self.controller.activate(int(item.data(_ROW)), new_tab)
def _on_item_entered(self, index: QModelIndex) -> None:
if index.data(_KIND) == "item":
self.controller.set_highlight(int(index.data(_ROW)))
def eventFilter(self, watched, event): # pylint: disable=invalid-name
"""Arrow keys, Enter and Escape in the search field drive the list."""
if watched is self.search and event.type() == QEvent.Type.KeyPress:
key = event.key()
if key == Qt.Key.Key_Down:
self.controller.move_highlight(1)
return True
if key == Qt.Key.Key_Up:
self.controller.move_highlight(-1)
return True
if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
ctrl = bool(event.modifiers() & Qt.KeyboardModifier.ControlModifier)
self.controller.activate(-1, ctrl and self.controller.hasTabs)
return True
if key == Qt.Key.Key_Escape:
self.close()
return True
return super().eventFilter(watched, event)
def open_at(self, anchor: QWidget | None) -> None:
"""Show the switcher below ``anchor``, right-aligned with it."""
self.search.blockSignals(True)
self.search.clear()
self.search.blockSignals(False)
self.controller.reset()
if anchor is not None:
pos = anchor.mapToGlobal(QPoint(anchor.width() - self.width(), anchor.height() + 4))
else:
parent = self.parentWidget()
center = parent.mapToGlobal(parent.rect().center()) if parent else QPoint(200, 200)
pos = center - QPoint(self.width() // 2, self.height() // 2)
screen = self.screen().availableGeometry() if self.screen() else None
if screen is not None:
pos.setX(max(screen.left(), min(pos.x(), screen.right() - self.width())))
pos.setY(max(screen.top(), min(pos.y(), screen.bottom() - self.height())))
self.move(pos)
self.show()
self.search.setFocus(Qt.FocusReason.PopupFocusReason)
def refresh_theme(self) -> None:
"""Apply the current theme colours."""
tokens = ThemeTokens()
self.delegate.tokens = tokens
self.setStyleSheet(
f"#profileSwitcher {{ background: {tokens.card.name()};"
f" border: 1px solid {tokens.border.name()}; border-radius: 10px; }}"
f"#switcherList {{ background: transparent; }}"
f"#switcherSeparator {{ background: {tokens.border.name()}; }}"
f"#switcherMuted {{ color: {tokens.fg_muted.name()}; font-size: 12px; padding: 16px; }}"
f"#switcherFooter {{ color: {tokens.fg_subtle.name()}; font-size: 11px; }}"
+ field_qss(tokens)
)
for button in self.footer_buttons.values():
button.refresh_theme(tokens)
self.list.viewport().update()
def paint_profile_button(combo: QWidget, painter: QPainter) -> None:
"""Paint the closed profile combo as a button: icon, profile name and a chevron.
Args:
combo(QWidget): The toolbar profile combo (a ``QComboBox``).
painter(QPainter): Active painter on ``combo``.
"""
tokens = ThemeTokens()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = combo.rect().adjusted(1, 1, -1, -1)
path = QPainterPath()
path.addRoundedRect(rect, 6, 6)
hovered = combo.underMouse()
painter.fillPath(path, tokens.hover if hovered else tokens.field)
painter.setPen(QPen(tokens.primary if combo.hasFocus() else tokens.border, 1))
painter.drawPath(path)
icon_rect = QRect(rect.left() + 8, rect.center().y() - 8, 16, 16)
material_icon("dashboard", size=(32, 32), color=tokens.fg_muted, convert_to_pixmap=False).paint(
painter, icon_rect
)
chevron = QRect(rect.right() - 24, rect.center().y() - 8, 16, 16)
material_icon(
"expand_more", size=(32, 32), color=tokens.fg_muted, convert_to_pixmap=False
).paint(painter, chevron)
text = combo.currentText()
font = QFont(combo.font())
font.setPixelSize(13)
if text:
font.setWeight(QFont.Weight.DemiBold)
painter.setPen(tokens.fg)
else:
text = "Unsaved workspace"
font.setItalic(True)
painter.setPen(tokens.fg_muted)
painter.setFont(font)
text_rect = QRect(
icon_rect.right() + 8, rect.top(), chevron.left() - icon_rect.right() - 12, rect.height()
)
painter.drawText(
text_rect,
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
painter.fontMetrics().elidedText(text, Qt.TextElideMode.ElideRight, text_rect.width()),
)
@@ -0,0 +1,165 @@
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Layouts
import BecUi
// The profile switcher: search, profiles with their saved previews, and Save as, Revert, library.
Rectangle {
id: root
required property QtObject backend
signal closeRequested()
color: theme.card
radius: 10
border.color: theme.border
border.width: 1
function focusSearch() { search.forceActiveFocus(); search.selectAll() }
function run(key) { root.closeRequested(); root.backend.request(key) }
ColumnLayout {
anchors.fill: parent
anchors.margins: 12
anchors.bottomMargin: 10
spacing: 8
InputField {
id: search
Layout.fillWidth: true
placeholderText: "Find a profile"
text: root.backend.query
onTextEdited: root.backend.set_query(text)
Keys.onDownPressed: root.backend.move_highlight(1)
Keys.onUpPressed: root.backend.move_highlight(-1)
Keys.onReturnPressed: (event) => root.backend.activate(-1, (event.modifiers & Qt.ControlModifier) && root.backend.hasTabs)
Keys.onEnterPressed: (event) => root.backend.activate(-1, (event.modifiers & Qt.ControlModifier) && root.backend.hasTabs)
Keys.onEscapePressed: root.closeRequested()
}
ListView {
id: list
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
model: root.backend.rowModel
currentIndex: root.backend.highlight
highlightFollowsCurrentItem: false
boundsBehavior: Flickable.StopAtBounds
onCurrentIndexChanged: positionViewAtIndex(currentIndex, ListView.Contain)
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
delegate: ItemDelegate {
id: row
required property int index
required property string name
required property string subtitle
required property string previewUrl
required property bool readOnly
required property bool isCurrent
required property bool isOpenElsewhere
required property string header
readonly property bool highlighted_: ListView.isCurrentItem
width: ListView.view.width
height: 68 + (header !== "" ? 30 : 0)
topPadding: header !== "" ? 30 : 0
Text {
visible: row.header !== ""
y: 0; height: 30
leftPadding: 12; topPadding: 10
text: row.header.toUpperCase()
color: theme.fgSubtle
font.pixelSize: 11
font.weight: Font.DemiBold
font.letterSpacing: 0.6
}
hoverEnabled: true
onHoveredChanged: if (hovered) root.backend.set_highlight(index)
onClicked: root.backend.activate(index, false)
TapHandler {
acceptedModifiers: Qt.ControlModifier
onTapped: root.backend.activate(row.index, root.backend.hasTabs)
}
background: Rectangle {
x: 4; width: row.width - 8; height: 66; y: (row.header !== "" ? 30 : 0) + 1
radius: 7
color: row.highlighted_ ? Qt.tint(theme.card, Qt.alpha(theme.primary, 0.14))
: row.hovered ? theme.hover : "transparent"
border.width: row.highlighted_ ? 1 : 0
border.color: Qt.tint(theme.card, Qt.alpha(theme.primary, 0.5))
}
contentItem: RowLayout {
spacing: 12
Rectangle {
Layout.leftMargin: 2
width: 96; height: 54; radius: 5
color: Qt.rgba(theme.fg.r, theme.fg.g, theme.fg.b, 0.05)
border.color: theme.border
clip: true
Image {
anchors.fill: parent
anchors.margins: 1
source: row.previewUrl
fillMode: Image.PreserveAspectCrop
smooth: true
visible: row.previewUrl !== ""
}
Icon {
anchors.centerIn: parent
visible: row.previewUrl === ""
name: "dashboard"; size: 22; color: theme.fgSubtle
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
RowLayout {
Layout.fillWidth: true
spacing: 6
Text {
Layout.fillWidth: true
text: row.name; color: theme.fg
font.pixelSize: 13; font.weight: Font.DemiBold
elide: Text.ElideRight
}
StatusPill { visible: row.readOnly; text: "Read-only"; tone: theme.fgMuted }
StatusPill { visible: row.isOpenElsewhere; text: "In another tab"; tone: theme.fgMuted }
StatusPill { visible: row.isCurrent; text: "Open here"; tone: theme.primary }
}
Text {
Layout.fillWidth: true
text: row.subtitle; color: theme.fgMuted; font.pixelSize: 12
elide: Text.ElideRight
}
}
}
}
}
Text {
visible: list.count === 0
Layout.fillWidth: true
Layout.margins: 16
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
text: root.backend.emptyText
color: theme.fgMuted
font.pixelSize: 12
}
Rectangle { Layout.fillWidth: true; height: 1; color: theme.border }
RowLayout {
spacing: 4
TextButton { text: "Save as…"; variant: "ghost"; iconName: "save"; tip: "Save the current layout as a profile"; onClicked: root.run("save") }
TextButton { text: "Revert…"; variant: "ghost"; iconName: "history"; enabled: root.backend.canRevert; tip: "Go back to the saved version of this profile"; onClicked: root.run("revert") }
TextButton { text: "All profiles…"; variant: "ghost"; iconName: "folder_open"; tip: "Open the profile library"; onClicked: root.run("library") }
Item { Layout.fillWidth: true }
}
Text {
text: "↑ ↓ choose · Enter open" + (root.backend.hasTabs ? " · Ctrl+Enter or Ctrl+click new tab" : "") + " · Esc close"
color: theme.fgSubtle
font.pixelSize: 11
}
}
}
@@ -7,7 +7,7 @@ from typing import Callable, Literal, Mapping, Sequence
import slugify
from bec_lib import bec_logger
from bec_qthemes import material_icon
from qtpy.QtCore import QSize, Qt, Signal
from qtpy.QtCore import QBuffer, QByteArray, QIODevice, QSize, Qt, Signal
from qtpy.QtGui import QPixmap
from qtpy.QtWidgets import (
QApplication,
@@ -89,6 +89,8 @@ from bec_widgets.widgets.containers.qt_ads import CDockWidget
logger = bec_logger.logger
_PROFILE_NAMESPACE_UNSET = object()
# Widest profile preview stored with the improved chrome, in pixels
PREVIEW_MAX_WIDTH = 1280
PROFILE_STATE_KEYS = {key: SETTINGS_KEYS[key] for key in ("geom", "state", "ads_state")}
StartupProfile = Literal["restore", "skip"] | str | None
@@ -646,6 +648,9 @@ class BECDockArea(DockAreaWidget):
self.toolbar.components.get_action("attach_all").action.setToolTip(
"Dock all floating windows back into this workspace"
)
self._chrome.attach_profile_switcher(
self.toolbar.components.get_action("workspace_combo").widget
)
self._refresh_modern_toolbar()
def _refresh_modern_toolbar(self) -> None:
@@ -673,10 +678,37 @@ class BECDockArea(DockAreaWidget):
self.screenshot_button.setIcon(
material_icon("photo_camera", size=(36, 36), color=tokens.fg_muted)
)
self.toolbar.components.get_action("workspace_combo").widget.update()
def _on_lock_button_toggled(self, checked: bool) -> None:
self._set_editable(not checked)
def preview_bytes(self) -> QByteArray:
"""
Image of the workspace for profile previews, encoded as PNG.
With the improved chrome only the docks are captured (no toolbar, no empty-state or
undo overlays) and the image is limited to 1280 px wide; otherwise the whole dock area.
Returns:
QByteArray: PNG bytes, empty when nothing could be captured.
"""
if self._chrome is None:
return self.screenshot_bytes()
pixmap = self.dock_manager.grab()
if pixmap.isNull():
return QByteArray()
if pixmap.width() > PREVIEW_MAX_WIDTH:
pixmap = pixmap.scaledToWidth(
PREVIEW_MAX_WIDTH, Qt.TransformationMode.SmoothTransformation
)
data = QByteArray()
buffer = QBuffer(data)
buffer.open(QIODevice.OpenModeFlag.WriteOnly)
pixmap.save(buffer, "PNG")
buffer.close()
return data
@SafeSlot()
def copy_screenshot(self) -> None:
"""Copy an image of the dock area to the clipboard."""
@@ -849,7 +881,7 @@ class BECDockArea(DockAreaWidget):
self.state_manager.save_state(settings=settings)
write_manifest(settings, self.dock_list())
if save_preview:
ba = self.screenshot_bytes()
ba = self.preview_bytes()
if ba and len(ba) > 0:
settings.setValue(SETTINGS_KEYS["screenshot"], ba)
settings.setValue(SETTINGS_KEYS["screenshot_at"], now_iso_utc())
@@ -1190,7 +1222,7 @@ class BECDockArea(DockAreaWidget):
current_pixmap = None
if self.isVisible():
current_pixmap = QPixmap()
ba = bytes(self.screenshot_bytes())
ba = bytes(self.preview_bytes())
current_pixmap.loadFromData(ba)
if current_pixmap is None or current_pixmap.isNull():
current_pixmap = load_runtime_profile_screenshot(target, namespace=namespace)
@@ -528,7 +528,8 @@ class ProfileActions:
if dock_area is None or not dock_area.isVisible():
return None
pixmap = QPixmap()
data = dock_area.screenshot_bytes()
capture = getattr(dock_area, "preview_bytes", dock_area.screenshot_bytes)
data = capture()
if not data or not pixmap.loadFromData(bytes(data)):
return None
return pixmap
@@ -3,8 +3,8 @@ from __future__ import annotations
from typing import Callable
from qtpy.QtCore import Qt
from qtpy.QtGui import QFont
from qtpy.QtWidgets import QComboBox, QSizePolicy
from qtpy.QtGui import QFont, QPainter
from qtpy.QtWidgets import QComboBox, QSizePolicy, QWidget
from bec_widgets import SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction, WidgetAction
@@ -20,10 +20,47 @@ class ProfileComboBox(QComboBox):
super().__init__(parent)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self._quick_provider: Callable[[], list[str]] = list_quick_profiles
self._switcher: Callable[[QWidget], None] | None = None
self._painter: Callable[[QComboBox, QPainter], None] | None = None
def set_quick_profile_provider(self, provider: Callable[[], list[str]]) -> None:
self._quick_provider = provider
def set_switcher(
self,
opener: Callable[[QWidget], None] | None,
painter: Callable[[QComboBox, QPainter], None] | None = None,
) -> None:
"""
Replace the drop-down list with a custom switcher popup.
Args:
opener(Callable | None): Called with this combo instead of showing the list; None
restores the plain list.
painter(Callable | None): Paints the closed combo instead of the default style.
"""
self._switcher = opener
self._painter = painter if opener is not None else None
self.update()
def showPopup(self): # pylint: disable=invalid-name
"""Open the switcher when one is installed, else the plain list."""
if self._switcher is not None:
self._switcher(self)
return
super().showPopup()
def paintEvent(self, event): # pylint: disable=invalid-name
"""Paint with the installed switcher painter, if any."""
if self._painter is None:
super().paintEvent(event)
return
painter = QPainter(self)
try:
self._painter(self, painter)
finally:
painter.end()
def _refresh_profiles(
self, current_text: str, active_profile: str | None = None, show_empty_profile: bool = False
) -> None:
+93
View File
@@ -245,3 +245,96 @@ def test_legacy_mode_keeps_old_chrome(qtbot, mocked_client, monkeypatch):
assert QtAds.CDockManager.testConfigFlag(flag.DockAreaHasCloseButton)
widget = area.new("Waveform")
assert area.dock_list()[0].windowTitle() == widget.objectName()
# ---------------------------------------------------------------------- profile switcher
def _save_two_profiles(area):
area.new("Waveform")
area.save_profile("Alpha", show_dialog=False, quick_select=True)
area.new("PositionerBox")
area.save_profile("Beta", show_dialog=False, quick_select=False)
area.load_profile("Alpha")
def test_profile_combo_opens_switcher(dock_area, qtbot):
_save_two_profiles(dock_area)
combo = dock_area.toolbar.components.get_action("workspace_combo").widget
combo.showPopup()
switcher = dock_area._chrome.switcher
assert switcher.isVisible()
rows = dock_area._chrome.switcher_controller.rows.items
assert [(r["name"], r["section"], r["isCurrent"]) for r in rows] == [
("Alpha", "In the toolbar list", True),
("Beta", "Other profiles", False),
]
assert dock_area._chrome.switcher_controller.highlight == 0
assert dock_area._chrome.switcher_controller.thumbnail_for("Alpha") is not None
def test_profile_switcher_search_and_open(dock_area, qtbot):
_save_two_profiles(dock_area)
dock_area._chrome.open_profile_switcher(None)
switcher = dock_area._chrome.switcher
switcher.search.setText("bet")
assert [r["name"] for r in dock_area._chrome.switcher_controller.rows.items] == ["Beta"]
qtbot.keyClick(switcher.search, Qt.Key.Key_Return)
assert dock_area._current_profile_name == "Beta"
assert not switcher.isVisible()
switcher.search.setText("nothing like this")
assert dock_area._chrome.switcher_controller.rows.count == 0
@pytest.mark.parametrize(
"key, method",
[
("save", "save_profile_dialog"),
("revert", "restore_baseline_profile"),
("library", "show_workspace_manager"),
],
)
def test_profile_switcher_footer(dock_area, key, method):
from unittest import mock
chrome = dock_area._chrome
chrome.open_profile_switcher(None)
requested = []
chrome.switcher_controller.action_requested.connect(requested.append)
chrome.switcher_controller.action_requested.disconnect(chrome._run_profile_action)
button = chrome.switcher.footer_buttons[key]
button.setEnabled(True)
button.click()
assert requested == [key]
assert not chrome.switcher.isVisible()
area = mock.MagicMock()
with mock.patch.object(chrome, "area", area):
chrome._run_profile_action(key)
getattr(area, method).assert_called_once()
def test_profile_preview_shows_only_the_docks(dock_area):
from qtpy.QtGui import QPixmap
dock_area.new("Waveform")
pixmap = QPixmap()
assert pixmap.loadFromData(bytes(dock_area.preview_bytes()))
assert pixmap.height() == dock_area.dock_manager.height()
assert pixmap.height() < dock_area.height()
def test_qml_profile_switcher_loads(qtbot, mocked_client, monkeypatch):
monkeypatch.setenv("BEC_DOCK_CHROME", "qml")
area = BECDockArea(client=mocked_client, startup_profile=None)
qtbot.addWidget(area)
area.show()
qtbot.waitExposed(area)
_save_two_profiles(area)
area._chrome.open_profile_switcher(None)
view = area._chrome.switcher.view
assert view.rootObject() is not None
assert not view.errors()
assert area._chrome.switcher_controller.rows.items[0]["previewUrl"].startswith("image://")
area._chrome.switcher_controller.activate(1)
assert area._current_profile_name == "Beta"