diff --git a/bec_widgets/widgets/containers/dock_area/chrome/__init__.py b/bec_widgets/widgets/containers/dock_area/chrome/__init__.py index d57bcb4f..a2cd2916 100644 --- a/bec_widgets/widgets/containers/dock_area/chrome/__init__.py +++ b/bec_widgets/widgets/containers/dock_area/chrome/__init__.py @@ -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 diff --git a/bec_widgets/widgets/containers/dock_area/chrome/chrome_qml.py b/bec_widgets/widgets/containers/dock_area/chrome/chrome_qml.py index b3f74e14..3037becf 100644 --- a/bec_widgets/widgets/containers/dock_area/chrome/chrome_qml.py +++ b/bec_widgets/widgets/containers/dock_area/chrome/chrome_qml.py @@ -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) diff --git a/bec_widgets/widgets/containers/dock_area/chrome/profile_switcher.py b/bec_widgets/widgets/containers/dock_area/chrome/profile_switcher.py new file mode 100644 index 00000000..ff2402b0 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/chrome/profile_switcher.py @@ -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) diff --git a/bec_widgets/widgets/containers/dock_area/chrome/profile_switcher_qwidget.py b/bec_widgets/widgets/containers/dock_area/chrome/profile_switcher_qwidget.py new file mode 100644 index 00000000..38ea8489 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/chrome/profile_switcher_qwidget.py @@ -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()), + ) diff --git a/bec_widgets/widgets/containers/dock_area/chrome/qml/ProfileSwitcher.qml b/bec_widgets/widgets/containers/dock_area/chrome/qml/ProfileSwitcher.qml new file mode 100644 index 00000000..f8a82266 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/chrome/qml/ProfileSwitcher.qml @@ -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 + } + } +} diff --git a/bec_widgets/widgets/containers/dock_area/dock_area.py b/bec_widgets/widgets/containers/dock_area/dock_area.py index 1f5bb3fa..af59d4ac 100644 --- a/bec_widgets/widgets/containers/dock_area/dock_area.py +++ b/bec_widgets/widgets/containers/dock_area/dock_area.py @@ -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) diff --git a/bec_widgets/widgets/containers/dock_area/profile_ux/common.py b/bec_widgets/widgets/containers/dock_area/profile_ux/common.py index c6ae310a..f47d09ef 100644 --- a/bec_widgets/widgets/containers/dock_area/profile_ux/common.py +++ b/bec_widgets/widgets/containers/dock_area/profile_ux/common.py @@ -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 diff --git a/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py b/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py index b233c035..b964d993 100644 --- a/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py +++ b/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py @@ -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: diff --git a/tests/unit_tests/test_dock_chrome.py b/tests/unit_tests/test_dock_chrome.py index 5d681184..d2dcfd25 100644 --- a/tests/unit_tests/test_dock_chrome.py +++ b/tests/unit_tests/test_dock_chrome.py @@ -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"