mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-11-03 09:05:53 +01:00
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:
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:
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user