mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-06-12 16:10:56 +02:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d99d5e1370 | |||
| 402c721279 | |||
| 6883910797 | |||
| 7de228a412 | |||
| c998e3ec48 | |||
| 1e3661c318 | |||
| 007a408e1a |
@@ -189,6 +189,7 @@ class LaunchTile(RoundedFrame):
|
||||
|
||||
class LaunchWindow(BECMainWindow):
|
||||
RPC = True
|
||||
PLUGIN = False
|
||||
TILE_SIZE = (250, 300)
|
||||
DEFAULT_LAUNCH_SIZE = (800, 600)
|
||||
USER_ACCESS = ["show_launcher", "hide_launcher"]
|
||||
|
||||
@@ -5,6 +5,7 @@ from bec_widgets.applications.navigation_centre.side_bar import SideBar
|
||||
from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem
|
||||
from bec_widgets.applications.views.developer_view.developer_view import DeveloperView
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView
|
||||
from bec_widgets.applications.views.dock_area_view.dock_area_view import DockAreaView
|
||||
from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.screen_utils import (
|
||||
@@ -12,11 +13,12 @@ from bec_widgets.utils.screen_utils import (
|
||||
available_screen_geometry,
|
||||
main_app_size_for_screen,
|
||||
)
|
||||
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
|
||||
|
||||
class BECMainApp(BECMainWindow):
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -50,13 +52,16 @@ class BECMainApp(BECMainWindow):
|
||||
|
||||
def _add_views(self):
|
||||
self.add_section("BEC Applications", "bec_apps")
|
||||
self.ads = BECDockArea(self, profile_namespace="bec", auto_profile_namespace=False)
|
||||
self.ads.setObjectName("MainWorkspace")
|
||||
self.dock_area = DockAreaView(self)
|
||||
self.device_manager = DeviceManagerView(self)
|
||||
self.developer_view = DeveloperView(self)
|
||||
|
||||
self.add_view(
|
||||
icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks"
|
||||
icon="widgets",
|
||||
title="Dock Area",
|
||||
id="dock_area",
|
||||
widget=self.dock_area,
|
||||
mini_text="Docks",
|
||||
)
|
||||
self.add_view(
|
||||
icon="display_settings",
|
||||
|
||||
@@ -79,6 +79,8 @@ def markdown_to_html(md_text: str) -> str:
|
||||
|
||||
|
||||
class DeveloperWidget(DockAreaWidget):
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, variant="compact", **kwargs)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.applications.views.view import ViewBase
|
||||
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
|
||||
|
||||
|
||||
class DockAreaView(ViewBase):
|
||||
"""
|
||||
Modular dock area view for arranging and managing multiple dockable widgets.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
id: str | None = None,
|
||||
title: str | None = None,
|
||||
):
|
||||
super().__init__(parent=parent, content=content, id=id, title=title)
|
||||
self.dock_area = BECDockArea(
|
||||
self, profile_namespace="bec", auto_profile_namespace=False, object_name="DockArea"
|
||||
)
|
||||
self.set_content(self.dock_area)
|
||||
@@ -2848,6 +2848,20 @@ class ImageItem(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class LaunchWindow(RPCBase):
|
||||
@rpc_call
|
||||
def show_launcher(self):
|
||||
"""
|
||||
Show the launcher window.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def hide_launcher(self):
|
||||
"""
|
||||
Hide the launcher window.
|
||||
"""
|
||||
|
||||
|
||||
class LogPanel(RPCBase):
|
||||
"""Displays a log panel"""
|
||||
|
||||
|
||||
@@ -291,7 +291,8 @@ def main():
|
||||
|
||||
client_path = module_dir / client_subdir / "client.py"
|
||||
|
||||
rpc_classes = get_custom_classes(module_name)
|
||||
packages = ("widgets", "applications") if module_name == "bec_widgets" else ("widgets",)
|
||||
rpc_classes = get_custom_classes(module_name, packages=packages)
|
||||
logger.info(f"Obtained classes with RPC objects: {rpc_classes!r}")
|
||||
|
||||
generator = ClientGenerator(base=module_name == "bec_widgets")
|
||||
|
||||
@@ -32,7 +32,8 @@ class RPCWidgetHandler:
|
||||
None
|
||||
"""
|
||||
self._widget_classes = (
|
||||
get_custom_classes("bec_widgets") + get_all_plugin_widgets()
|
||||
get_custom_classes("bec_widgets", packages=("widgets", "applications"))
|
||||
+ get_all_plugin_widgets()
|
||||
).as_dict(IGNORE_WIDGETS)
|
||||
|
||||
def create_widget(self, widget_type, **kwargs) -> BECWidget:
|
||||
|
||||
@@ -12,7 +12,7 @@ import shiboken6 as shb
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import lazy_import_from
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, QTimer, Signal
|
||||
from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, Signal
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
@@ -23,7 +23,6 @@ from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, s
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.widgets.containers.dock import BECDock
|
||||
else:
|
||||
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
|
||||
|
||||
@@ -273,6 +272,8 @@ class BECConnector:
|
||||
Args:
|
||||
name (str): The new object name.
|
||||
"""
|
||||
# sanitize before setting to avoid issues with Qt object names and RPC namespaces
|
||||
name = sanitize_namespace(name)
|
||||
super().setObjectName(name)
|
||||
self.object_name = name
|
||||
if self.rpc_register.object_is_registered(self):
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Login dialog for user authentication.
|
||||
The Login Widget is styled in a Material Design style and emits
|
||||
the entered credentials through a signal for further processing.
|
||||
"""
|
||||
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtWidgets import QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
|
||||
class BECLogin(QWidget):
|
||||
"""Login dialog for user authentication in Material Design style."""
|
||||
|
||||
credentials_entered = Signal(str, str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
# Only displayed if this widget as standalone widget, and not embedded in another widget
|
||||
self.setWindowTitle("Login")
|
||||
|
||||
title = QLabel("Sign in", parent=self)
|
||||
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
title.setStyleSheet(
|
||||
"""
|
||||
#QLabel
|
||||
{
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
self.username = QLineEdit(parent=self)
|
||||
self.username.setPlaceholderText("Username")
|
||||
|
||||
self.password = QLineEdit(parent=self)
|
||||
self.password.setPlaceholderText("Password")
|
||||
self.password.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
|
||||
self.ok_btn = QPushButton("Sign in", parent=self)
|
||||
self.ok_btn.setDefault(True)
|
||||
self.ok_btn.clicked.connect(self._emit_credentials)
|
||||
# If the user presses Enter in the password field, trigger the OK button click
|
||||
self.password.returnPressed.connect(self.ok_btn.click)
|
||||
|
||||
# Build Layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(32, 32, 32, 32)
|
||||
layout.setSpacing(16)
|
||||
|
||||
layout.addWidget(title)
|
||||
layout.addSpacing(8)
|
||||
layout.addWidget(self.username)
|
||||
layout.addWidget(self.password)
|
||||
layout.addSpacing(12)
|
||||
layout.addWidget(self.ok_btn)
|
||||
|
||||
self.username.setFocus()
|
||||
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
QLineEdit {
|
||||
padding: 8px;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
def _clear_password(self):
|
||||
"""Clear the password field."""
|
||||
self.password.clear()
|
||||
|
||||
def _emit_credentials(self):
|
||||
"""Emit credentials and clear the password field."""
|
||||
self.credentials_entered.emit(self.username.text().strip(), self.password.text())
|
||||
self._clear_password()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("light")
|
||||
|
||||
dialog = BECLogin()
|
||||
|
||||
dialog.credentials_entered.connect(lambda u, p: print(f"Username: {u}, Password: {p}"))
|
||||
dialog.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Iterable
|
||||
|
||||
from bec_lib.plugin_helper import _get_available_plugins
|
||||
from qtpy.QtWidgets import QGraphicsWidget, QWidget
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
@@ -166,18 +166,17 @@ class BECClassContainer:
|
||||
return [info.obj for info in self.collection]
|
||||
|
||||
|
||||
def get_custom_classes(repo_name: str) -> BECClassContainer:
|
||||
"""
|
||||
Get all RPC-enabled classes in the specified repository.
|
||||
|
||||
Args:
|
||||
repo_name(str): The name of the repository.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
|
||||
"""
|
||||
def _collect_classes_from_package(repo_name: str, package: str) -> BECClassContainer:
|
||||
"""Collect classes from a package subtree (for example ``widgets`` or ``applications``)."""
|
||||
collection = BECClassContainer()
|
||||
anchor_module = importlib.import_module(f"{repo_name}.widgets")
|
||||
try:
|
||||
anchor_module = importlib.import_module(f"{repo_name}.{package}")
|
||||
except ModuleNotFoundError as exc:
|
||||
# Some plugin repositories expose only one subtree. Skip gracefully if it does not exist.
|
||||
if exc.name == f"{repo_name}.{package}":
|
||||
return collection
|
||||
raise
|
||||
|
||||
directory = os.path.dirname(anchor_module.__file__)
|
||||
for root, _, files in sorted(os.walk(directory)):
|
||||
for file in files:
|
||||
@@ -185,13 +184,13 @@ def get_custom_classes(repo_name: str) -> BECClassContainer:
|
||||
continue
|
||||
|
||||
path = os.path.join(root, file)
|
||||
subs = os.path.dirname(os.path.relpath(path, directory)).split("/")
|
||||
if len(subs) == 1 and not subs[0]:
|
||||
rel_dir = os.path.dirname(os.path.relpath(path, directory))
|
||||
if rel_dir in ("", "."):
|
||||
module_name = file.split(".")[0]
|
||||
else:
|
||||
module_name = ".".join(subs + [file.split(".")[0]])
|
||||
module_name = ".".join(rel_dir.split(os.sep) + [file.split(".")[0]])
|
||||
|
||||
module = importlib.import_module(f"{repo_name}.widgets.{module_name}")
|
||||
module = importlib.import_module(f"{repo_name}.{package}.{module_name}")
|
||||
|
||||
for name in dir(module):
|
||||
obj = getattr(module, name)
|
||||
@@ -203,12 +202,30 @@ def get_custom_classes(repo_name: str) -> BECClassContainer:
|
||||
class_info.is_connector = True
|
||||
if issubclass(obj, QWidget) or issubclass(obj, BECWidget):
|
||||
class_info.is_widget = True
|
||||
if len(subs) == 1 and (
|
||||
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
|
||||
):
|
||||
class_info.is_top_level = True
|
||||
if hasattr(obj, "PLUGIN") and obj.PLUGIN:
|
||||
class_info.is_plugin = True
|
||||
collection.add_class(class_info)
|
||||
|
||||
return collection
|
||||
|
||||
|
||||
def get_custom_classes(
|
||||
repo_name: str, packages: tuple[str, ...] | None = None
|
||||
) -> BECClassContainer:
|
||||
"""
|
||||
Get all relevant classes for RPC/CLI in the specified repository.
|
||||
|
||||
By default, discovery is limited to ``<repo>.widgets`` for backward compatibility.
|
||||
Additional package subtrees (for example ``applications``) can be included explicitly.
|
||||
|
||||
Args:
|
||||
repo_name(str): The name of the repository.
|
||||
packages(tuple[str, ...] | None): Optional tuple of package names to scan. Defaults to ("widgets",) for backward compatibility.
|
||||
|
||||
Returns:
|
||||
BECClassContainer: Container with collected class information.
|
||||
"""
|
||||
selected_packages = packages or ("widgets",)
|
||||
collection = BECClassContainer()
|
||||
for package in selected_packages:
|
||||
collection += _collect_classes_from_package(repo_name, package)
|
||||
return collection
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib import messages
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from qtpy.QtCore import QEvent, QSize, Qt, QTimer
|
||||
from qtpy.QtGui import QAction, QActionGroup, QIcon
|
||||
@@ -31,6 +32,7 @@ from bec_widgets.widgets.containers.main_window.addons.notification_center.notif
|
||||
from bec_widgets.widgets.containers.main_window.addons.scroll_label import ScrollLabel
|
||||
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
|
||||
from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import ScanProgressBar
|
||||
from bec_widgets.widgets.utility.feedback_dialog.feedback_dialog import FeedbackDialog
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
@@ -342,6 +344,34 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
help_menu.addAction(widgets_docs)
|
||||
help_menu.addAction(bug_report)
|
||||
|
||||
# Add separator before feedback
|
||||
help_menu.addSeparator()
|
||||
|
||||
# Feedback action
|
||||
feedback_icon = QApplication.style().standardIcon(
|
||||
QStyle.StandardPixmap.SP_MessageBoxQuestion
|
||||
)
|
||||
feedback_action = QAction("Feedback", self)
|
||||
feedback_action.setIcon(feedback_icon)
|
||||
feedback_action.triggered.connect(self._show_feedback_dialog)
|
||||
help_menu.addAction(feedback_action)
|
||||
|
||||
def _show_feedback_dialog(self):
|
||||
"""Show the feedback dialog and handle the submitted feedback."""
|
||||
dialog = FeedbackDialog(self)
|
||||
|
||||
def on_feedback_submitted(rating: int, comment: str, email: str):
|
||||
rating = max(1, min(rating, 5)) # Ensure rating is between 1 and 5
|
||||
username = os.getlogin()
|
||||
|
||||
message = messages.FeedbackMessage(
|
||||
feedback=comment, rating=rating, contact=email, username=username
|
||||
)
|
||||
self.bec_dispatcher.client.connector.send(MessageEndpoints.submit_feedback(), message)
|
||||
|
||||
dialog.feedback_submitted.connect(on_feedback_submitted)
|
||||
dialog.exec()
|
||||
|
||||
################################################################################
|
||||
# Status Bar Addons
|
||||
################################################################################
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtGui import QColor, QFont
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeConnect, SafeSlot
|
||||
|
||||
|
||||
class StarRating(QWidget):
|
||||
"""
|
||||
A star rating widget that allows users to rate from 1 to 5 stars.
|
||||
"""
|
||||
|
||||
rating_changed = Signal(int)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._rating = 0
|
||||
self._hovered_star = 0
|
||||
self._star_buttons = []
|
||||
|
||||
# Get theme colors
|
||||
theme = getattr(QApplication.instance(), "theme", None)
|
||||
if theme:
|
||||
SafeConnect(self, theme.theme_changed, self._update_theme_colors)
|
||||
self._update_theme_colors()
|
||||
|
||||
# Enable mouse tracking to handle hover across the entire widget
|
||||
self.setMouseTracking(True)
|
||||
|
||||
layout = QHBoxLayout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(5)
|
||||
|
||||
for i in range(5):
|
||||
btn = QPushButton("★")
|
||||
btn.setFixedSize(30, 30)
|
||||
btn.setFlat(True)
|
||||
btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
btn.clicked.connect(lambda checked=False, idx=i + 1: self._set_rating(idx))
|
||||
layout.addWidget(btn)
|
||||
self._star_buttons.append(btn)
|
||||
|
||||
self.setLayout(layout)
|
||||
self._update_display()
|
||||
|
||||
@SafeSlot(str)
|
||||
def _update_theme_colors(self, _theme: str | None = None):
|
||||
"""Update colors based on theme."""
|
||||
theme = getattr(QApplication.instance(), "theme", None)
|
||||
colors = theme.colors if theme else {}
|
||||
|
||||
self._inactive_color = colors.get("SEPARATOR", QColor(200, 200, 200))
|
||||
self._active_color = colors.get("ACCENT_WARNING", QColor(255, 193, 7))
|
||||
|
||||
# Update display if already initialized
|
||||
if hasattr(self, "_star_buttons") and self._star_buttons:
|
||||
self._update_display()
|
||||
|
||||
def _set_rating(self, rating: int):
|
||||
"""Set the rating and emit the signal."""
|
||||
if self._rating != rating:
|
||||
self._rating = rating
|
||||
self.rating_changed.emit(rating)
|
||||
self._update_display()
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
"""Handle mouse movement to update hovered star."""
|
||||
# Calculate which star is being hovered based on mouse position
|
||||
x_pos = event.pos().x()
|
||||
star_idx = 0
|
||||
|
||||
# Find which star region we're in (including gaps between stars)
|
||||
for i, btn in enumerate(self._star_buttons):
|
||||
btn_geometry = btn.geometry()
|
||||
# If we're to the right of this button's left edge, this is the current star
|
||||
# (including the gap before the next button)
|
||||
if x_pos >= btn_geometry.left():
|
||||
star_idx = i + 1
|
||||
else:
|
||||
break
|
||||
|
||||
if star_idx != self._hovered_star:
|
||||
self._hovered_star = star_idx
|
||||
self._update_display()
|
||||
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def leaveEvent(self, event):
|
||||
"""Handle mouse leaving the widget."""
|
||||
self._hovered_star = 0
|
||||
self._update_display()
|
||||
super().leaveEvent(event)
|
||||
|
||||
def _update_display(self):
|
||||
"""Update the visual display of stars."""
|
||||
display_rating = self._hovered_star if self._hovered_star > 0 else self._rating
|
||||
inactive_color_name = self._inactive_color.name()
|
||||
active_color_name = self._active_color.name()
|
||||
|
||||
for i, btn in enumerate(self._star_buttons):
|
||||
if i < display_rating:
|
||||
btn.setStyleSheet(
|
||||
f"""
|
||||
QPushButton {{
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 24px;
|
||||
color: {active_color_name};
|
||||
}}
|
||||
"""
|
||||
)
|
||||
else:
|
||||
btn.setStyleSheet(
|
||||
f"""
|
||||
QPushButton {{
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 24px;
|
||||
color: {inactive_color_name};
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
color: {active_color_name};
|
||||
}}
|
||||
"""
|
||||
)
|
||||
|
||||
def rating(self) -> int:
|
||||
"""Get the current rating."""
|
||||
return self._rating
|
||||
|
||||
def set_rating(self, rating: int):
|
||||
"""Set the rating programmatically."""
|
||||
if 0 <= rating <= 5:
|
||||
self._set_rating(rating)
|
||||
|
||||
|
||||
class FeedbackDialog(QDialog):
|
||||
"""
|
||||
A feedback dialog widget containing a comment field, star rating, and optional email field.
|
||||
|
||||
Signals:
|
||||
feedbackSubmitted: Emitted when feedback is submitted (rating: int, comment: str, email: str)
|
||||
"""
|
||||
|
||||
feedback_submitted = Signal(int, str, str)
|
||||
ICON_NAME = "feedback"
|
||||
PLUGIN = True
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Feedback")
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(400)
|
||||
self.setMinimumHeight(300)
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the user interface."""
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(15)
|
||||
|
||||
# Title
|
||||
title_label = QLabel("We'd love to hear your feedback!")
|
||||
title_font = QFont()
|
||||
title_font.setPointSize(12)
|
||||
title_font.setBold(True)
|
||||
title_label.setFont(title_font)
|
||||
layout.addWidget(title_label)
|
||||
|
||||
# Star rating section
|
||||
rating_layout = QVBoxLayout()
|
||||
rating_label = QLabel("Rating:")
|
||||
rating_layout.addWidget(rating_label)
|
||||
|
||||
self._star_rating = StarRating()
|
||||
rating_layout.addWidget(self._star_rating)
|
||||
layout.addLayout(rating_layout)
|
||||
|
||||
# Comment section
|
||||
comment_label = QLabel("Comments:")
|
||||
layout.addWidget(comment_label)
|
||||
|
||||
self._comment_field = QTextEdit()
|
||||
self._comment_field.setPlaceholderText("Please share your thoughts...")
|
||||
self._comment_field.setMaximumHeight(150)
|
||||
layout.addWidget(self._comment_field)
|
||||
|
||||
# Email section (optional)
|
||||
email_label = QLabel("Email (optional, for follow-up):")
|
||||
layout.addWidget(email_label)
|
||||
|
||||
self._email_field = QLineEdit()
|
||||
self._email_field.setPlaceholderText("your.email@example.com")
|
||||
layout.addWidget(self._email_field)
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.addStretch()
|
||||
|
||||
self._cancel_button = QPushButton("Cancel")
|
||||
self._cancel_button.clicked.connect(self.reject)
|
||||
button_layout.addWidget(self._cancel_button)
|
||||
|
||||
self._submit_button = QPushButton("Submit")
|
||||
self._submit_button.setDefault(True)
|
||||
self._submit_button.clicked.connect(self._on_submit)
|
||||
button_layout.addWidget(self._submit_button)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def _on_submit(self):
|
||||
"""Handle submit button click."""
|
||||
rating = self._star_rating.rating()
|
||||
comment = self._comment_field.toPlainText().strip()
|
||||
email = self._email_field.text().strip()
|
||||
|
||||
# Emit the feedback signal
|
||||
self.feedback_submitted.emit(rating, comment, email)
|
||||
|
||||
# Accept the dialog
|
||||
self.accept()
|
||||
|
||||
def get_feedback(self) -> tuple[int, str, str]:
|
||||
"""
|
||||
Get the current feedback values.
|
||||
|
||||
Returns:
|
||||
tuple: (rating, comment, email)
|
||||
"""
|
||||
return (
|
||||
self._star_rating.rating(),
|
||||
self._comment_field.toPlainText().strip(),
|
||||
self._email_field.text().strip(),
|
||||
)
|
||||
|
||||
def set_rating(self, rating: int):
|
||||
"""Set the star rating."""
|
||||
self._star_rating.set_rating(rating)
|
||||
|
||||
def set_comment(self, comment: str):
|
||||
"""Set the comment text."""
|
||||
self._comment_field.setPlainText(comment)
|
||||
|
||||
def set_email(self, email: str):
|
||||
"""Set the email text."""
|
||||
self._email_field.setText(email)
|
||||
|
||||
@staticmethod
|
||||
def show_feedback_dialog(parent=None) -> tuple[int, str, str] | None:
|
||||
"""
|
||||
Show the feedback dialog and return the feedback if submitted.
|
||||
|
||||
Args:
|
||||
parent: Parent widget
|
||||
|
||||
Returns:
|
||||
tuple: (rating, comment, email) if submitted, None if cancelled
|
||||
"""
|
||||
dialog = FeedbackDialog(parent)
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
return dialog.get_feedback()
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
dialog = FeedbackDialog()
|
||||
|
||||
def on_feedback(rating, comment, email):
|
||||
print(f"Rating: {rating}")
|
||||
print(f"Comment: {comment}")
|
||||
print(f"Email: {email}")
|
||||
|
||||
dialog.feedback_submitted.connect(on_feedback)
|
||||
dialog.exec()
|
||||
sys.exit(app.exec())
|
||||
@@ -39,6 +39,18 @@ def test_bec_connector_set_gui_id(bec_connector):
|
||||
assert bec_connector.config.gui_id == "test_gui_id"
|
||||
|
||||
|
||||
def test_bec_connector_sanitize_names(mocked_client):
|
||||
class MyWidget(BECConnector, QWidget):
|
||||
def __init__(self, parent=None, client=None, **kwargs):
|
||||
super().__init__(parent=parent, client=client, **kwargs)
|
||||
|
||||
widget = MyWidget(client=mocked_client)
|
||||
widget.setObjectName("Test Name With Spaces")
|
||||
assert widget.objectName() == "Test_Name_With_Spaces"
|
||||
widget.setObjectName("Test@Name#With$Special%Characters!")
|
||||
assert widget.objectName() == "Test_Name_With_Special_Characters_"
|
||||
|
||||
|
||||
def test_bec_connector_change_config(bec_connector):
|
||||
bec_connector.on_config_update({"gui_id": "test_gui_id"})
|
||||
assert bec_connector.config.gui_id == "test_gui_id"
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import pytest
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QDialog
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.utility.feedback_dialog.feedback_dialog import FeedbackDialog, StarRating
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def star_rating(qtbot):
|
||||
"""Create a StarRating widget for testing."""
|
||||
widget = StarRating()
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
widget.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def feedback_dialog(qtbot):
|
||||
"""Create a FeedbackDialog for testing."""
|
||||
dialog = FeedbackDialog()
|
||||
qtbot.addWidget(dialog)
|
||||
qtbot.waitExposed(dialog)
|
||||
yield dialog
|
||||
dialog.close()
|
||||
|
||||
|
||||
class TestStarRating:
|
||||
"""Tests for the StarRating widget."""
|
||||
|
||||
def test_initial_state(self, star_rating):
|
||||
"""Test that StarRating initializes with rating 0."""
|
||||
assert star_rating.rating() == 0
|
||||
assert star_rating._hovered_star == 0
|
||||
assert len(star_rating._star_buttons) == 5
|
||||
|
||||
def test_set_rating_via_method(self, star_rating):
|
||||
"""Test setting rating programmatically."""
|
||||
star_rating.set_rating(3)
|
||||
assert star_rating.rating() == 3
|
||||
|
||||
star_rating.set_rating(5)
|
||||
assert star_rating.rating() == 5
|
||||
|
||||
def test_set_rating_bounds(self, star_rating):
|
||||
"""Test that rating is bounded between 0 and 5."""
|
||||
star_rating.set_rating(0)
|
||||
assert star_rating.rating() == 0
|
||||
|
||||
star_rating.set_rating(5)
|
||||
assert star_rating.rating() == 5
|
||||
|
||||
# Out of bounds should not change rating
|
||||
initial_rating = star_rating.rating()
|
||||
star_rating.set_rating(6)
|
||||
assert star_rating.rating() == initial_rating
|
||||
|
||||
star_rating.set_rating(-1)
|
||||
assert star_rating.rating() == initial_rating
|
||||
|
||||
def test_rating_signal_emission(self, star_rating, qtbot):
|
||||
"""Test that rating_changed signal is emitted when rating changes."""
|
||||
with qtbot.waitSignal(star_rating.rating_changed, timeout=1000) as blocker:
|
||||
star_rating.set_rating(4)
|
||||
|
||||
assert blocker.args == [4]
|
||||
|
||||
def test_rating_signal_not_emitted_on_same_value(self, star_rating, qtbot):
|
||||
"""Test that signal is not emitted when setting the same rating."""
|
||||
star_rating.set_rating(3)
|
||||
|
||||
# Should not emit signal when setting same value
|
||||
with qtbot.assertNotEmitted(star_rating.rating_changed, wait=100):
|
||||
star_rating.set_rating(3)
|
||||
|
||||
def test_click_star_button(self, star_rating, qtbot):
|
||||
"""Test clicking on star buttons."""
|
||||
# Click the third star (index 2)
|
||||
with qtbot.waitSignal(star_rating.rating_changed, timeout=1000):
|
||||
qtbot.mouseClick(star_rating._star_buttons[2], Qt.LeftButton)
|
||||
|
||||
assert star_rating.rating() == 3
|
||||
|
||||
# Click the first star
|
||||
with qtbot.waitSignal(star_rating.rating_changed, timeout=1000):
|
||||
qtbot.mouseClick(star_rating._star_buttons[0], Qt.LeftButton)
|
||||
|
||||
assert star_rating.rating() == 1
|
||||
|
||||
def test_mouse_hover(self, star_rating, qtbot):
|
||||
"""Test mouse hover behavior."""
|
||||
# Set initial rating
|
||||
star_rating.set_rating(2)
|
||||
assert star_rating._hovered_star == 0
|
||||
|
||||
# Simulate mouse move over the fourth button
|
||||
btn = star_rating._star_buttons[3]
|
||||
btn_center = btn.geometry().center()
|
||||
event = qtbot.mouseMove(star_rating, pos=btn_center)
|
||||
|
||||
# Note: _hovered_star should be updated by mouseMoveEvent
|
||||
# This is a bit tricky to test directly, so we verify the method exists
|
||||
assert hasattr(star_rating, "mouseMoveEvent")
|
||||
assert hasattr(star_rating, "leaveEvent")
|
||||
|
||||
def test_leave_event(self, star_rating, qtbot):
|
||||
"""Test that leaving the widget clears hover state."""
|
||||
star_rating.set_rating(2)
|
||||
star_rating._hovered_star = 4 # Simulate hover
|
||||
|
||||
# Trigger leave event
|
||||
star_rating.leaveEvent(None)
|
||||
|
||||
assert star_rating._hovered_star == 0
|
||||
assert star_rating.rating() == 2 # Rating should remain unchanged
|
||||
|
||||
def test_update_theme_colors(self, star_rating):
|
||||
"""Test that theme colors are applied correctly."""
|
||||
assert hasattr(star_rating, "_inactive_color")
|
||||
assert hasattr(star_rating, "_active_color")
|
||||
|
||||
# Colors should be initialized
|
||||
assert star_rating._inactive_color is not None
|
||||
assert star_rating._active_color is not None
|
||||
|
||||
def test_display_update(self, star_rating):
|
||||
"""Test that display updates when rating changes."""
|
||||
star_rating.set_rating(3)
|
||||
# If this doesn't raise an exception, the display was updated successfully
|
||||
star_rating._update_display()
|
||||
|
||||
|
||||
class TestFeedbackDialog:
|
||||
"""Tests for the FeedbackDialog widget."""
|
||||
|
||||
def test_initial_state(self, feedback_dialog):
|
||||
"""Test that FeedbackDialog initializes correctly."""
|
||||
assert feedback_dialog.windowTitle() == "Feedback"
|
||||
assert feedback_dialog.isModal() is True
|
||||
assert feedback_dialog._star_rating is not None
|
||||
assert feedback_dialog._comment_field is not None
|
||||
assert feedback_dialog._email_field is not None
|
||||
assert feedback_dialog._submit_button is not None
|
||||
assert feedback_dialog._cancel_button is not None
|
||||
|
||||
def test_get_feedback_initial(self, feedback_dialog):
|
||||
"""Test getting feedback from unmodified dialog."""
|
||||
rating, comment, email = feedback_dialog.get_feedback()
|
||||
assert rating == 0
|
||||
assert comment == ""
|
||||
assert email == ""
|
||||
|
||||
def test_set_and_get_rating(self, feedback_dialog):
|
||||
"""Test setting and getting rating."""
|
||||
feedback_dialog.set_rating(4)
|
||||
rating, _, _ = feedback_dialog.get_feedback()
|
||||
assert rating == 4
|
||||
|
||||
def test_set_and_get_comment(self, feedback_dialog):
|
||||
"""Test setting and getting comment."""
|
||||
test_comment = "This is a test comment"
|
||||
feedback_dialog.set_comment(test_comment)
|
||||
_, comment, _ = feedback_dialog.get_feedback()
|
||||
assert comment == test_comment
|
||||
|
||||
def test_set_and_get_email(self, feedback_dialog):
|
||||
"""Test setting and getting email."""
|
||||
test_email = "test@example.com"
|
||||
feedback_dialog.set_email(test_email)
|
||||
_, _, email = feedback_dialog.get_feedback()
|
||||
assert email == test_email
|
||||
|
||||
def test_set_all_feedback(self, feedback_dialog):
|
||||
"""Test setting all feedback fields."""
|
||||
feedback_dialog.set_rating(5)
|
||||
feedback_dialog.set_comment("Great widget!")
|
||||
feedback_dialog.set_email("user@example.com")
|
||||
|
||||
rating, comment, email = feedback_dialog.get_feedback()
|
||||
assert rating == 5
|
||||
assert comment == "Great widget!"
|
||||
assert email == "user@example.com"
|
||||
|
||||
def test_submit_button_emits_signal(self, feedback_dialog, qtbot):
|
||||
"""Test that clicking submit emits feedback_submitted signal."""
|
||||
feedback_dialog.set_rating(3)
|
||||
feedback_dialog.set_comment("Test feedback")
|
||||
feedback_dialog.set_email("test@test.com")
|
||||
|
||||
with qtbot.waitSignal(feedback_dialog.feedback_submitted, timeout=1000) as blocker:
|
||||
qtbot.mouseClick(feedback_dialog._submit_button, Qt.LeftButton)
|
||||
|
||||
assert blocker.args == [3, "Test feedback", "test@test.com"]
|
||||
|
||||
def test_submit_button_accepts_dialog(self, feedback_dialog, qtbot):
|
||||
"""Test that clicking submit accepts the dialog."""
|
||||
feedback_dialog.set_rating(4)
|
||||
|
||||
qtbot.mouseClick(feedback_dialog._submit_button, Qt.LeftButton)
|
||||
qtbot.wait(100)
|
||||
|
||||
# Dialog should be accepted
|
||||
assert feedback_dialog.result() == QDialog.DialogCode.Accepted
|
||||
|
||||
def test_cancel_button_rejects_dialog(self, feedback_dialog, qtbot):
|
||||
"""Test that clicking cancel rejects the dialog."""
|
||||
qtbot.mouseClick(feedback_dialog._cancel_button, Qt.LeftButton)
|
||||
qtbot.wait(100)
|
||||
|
||||
# Dialog should be rejected
|
||||
assert feedback_dialog.result() == QDialog.DialogCode.Rejected
|
||||
|
||||
def test_submit_with_empty_fields(self, feedback_dialog, qtbot):
|
||||
"""Test submitting with empty fields."""
|
||||
# Don't set any values
|
||||
with qtbot.waitSignal(feedback_dialog.feedback_submitted, timeout=1000) as blocker:
|
||||
qtbot.mouseClick(feedback_dialog._submit_button, Qt.LeftButton)
|
||||
|
||||
# Should emit with empty values
|
||||
assert blocker.args == [0, "", ""]
|
||||
|
||||
def test_submit_strips_whitespace(self, feedback_dialog, qtbot):
|
||||
"""Test that whitespace is stripped from comment and email."""
|
||||
feedback_dialog.set_comment(" Test comment ")
|
||||
feedback_dialog.set_email(" test@example.com ")
|
||||
|
||||
with qtbot.waitSignal(feedback_dialog.feedback_submitted, timeout=1000) as blocker:
|
||||
qtbot.mouseClick(feedback_dialog._submit_button, Qt.LeftButton)
|
||||
|
||||
rating, comment, email = blocker.args
|
||||
assert comment == "Test comment"
|
||||
assert email == "test@example.com"
|
||||
|
||||
def test_dialog_has_correct_properties(self, feedback_dialog):
|
||||
"""Test that dialog has correct class properties."""
|
||||
assert hasattr(FeedbackDialog, "ICON_NAME")
|
||||
assert FeedbackDialog.ICON_NAME == "feedback"
|
||||
assert hasattr(FeedbackDialog, "PLUGIN")
|
||||
assert FeedbackDialog.PLUGIN is True
|
||||
|
||||
def test_comment_field_placeholder(self, feedback_dialog):
|
||||
"""Test that comment field has placeholder text."""
|
||||
assert feedback_dialog._comment_field.placeholderText() != ""
|
||||
|
||||
def test_email_field_placeholder(self, feedback_dialog):
|
||||
"""Test that email field has placeholder text."""
|
||||
assert feedback_dialog._email_field.placeholderText() != ""
|
||||
|
||||
def test_submit_button_is_default(self, feedback_dialog):
|
||||
"""Test that submit button is set as default."""
|
||||
assert feedback_dialog._submit_button.isDefault() is True
|
||||
|
||||
def test_star_rating_embedded_correctly(self, feedback_dialog, qtbot):
|
||||
"""Test that StarRating widget is properly embedded."""
|
||||
# Verify we can interact with the embedded star rating
|
||||
feedback_dialog._star_rating.set_rating(5)
|
||||
assert feedback_dialog._star_rating.rating() == 5
|
||||
|
||||
# Verify rating is reflected in feedback
|
||||
rating, _, _ = feedback_dialog.get_feedback()
|
||||
assert rating == 5
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Test the BEC Login widget"""
|
||||
|
||||
import pytest
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QLineEdit
|
||||
|
||||
from bec_widgets.utils.bec_login import BECLogin
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def login_dialog(qtbot):
|
||||
"""Fixture to create a BECLogin instance."""
|
||||
dialog = BECLogin()
|
||||
qtbot.addWidget(dialog)
|
||||
qtbot.waitExposed(dialog) # Ensure the dialog is fully shown before running tests
|
||||
return dialog
|
||||
|
||||
|
||||
def test_utils_login_dialog_initialization(login_dialog, qtbot):
|
||||
"""Test that the BECLogin initializes correctly."""
|
||||
assert login_dialog.windowTitle() == "Login"
|
||||
assert login_dialog.username.placeholderText() == "Username"
|
||||
assert login_dialog.password.placeholderText() == "Password"
|
||||
assert login_dialog.password.echoMode() == QLineEdit.EchoMode.Password
|
||||
assert login_dialog.ok_btn.text() == "Sign in"
|
||||
|
||||
# Initially, this should be empty
|
||||
with qtbot.waitSignal(login_dialog.credentials_entered, timeout=5000) as blocker:
|
||||
qtbot.mouseClick(login_dialog.ok_btn, Qt.MouseButton.LeftButton)
|
||||
assert blocker.args == ["", ""]
|
||||
|
||||
|
||||
def test_utils_login_dialog_emit_credentials(login_dialog, qtbot):
|
||||
"""Test that the BECLogin emits credentials correctly."""
|
||||
test_username = "testuser "
|
||||
test_password = "testpass"
|
||||
|
||||
login_dialog.username.setText(test_username)
|
||||
login_dialog.password.setText(test_password)
|
||||
|
||||
with qtbot.waitSignal(login_dialog.credentials_entered, timeout=5000) as blocker:
|
||||
qtbot.mouseClick(login_dialog.ok_btn, Qt.MouseButton.LeftButton)
|
||||
|
||||
assert blocker.args == [test_username.strip(), test_password]
|
||||
assert login_dialog.password.text() == "" # Password should be cleared after emitting
|
||||
|
||||
login_dialog.password.setText(test_password)
|
||||
with qtbot.waitSignal(login_dialog.credentials_entered, timeout=5000) as blocker:
|
||||
qtbot.keyClick(login_dialog.password, Qt.Key.Key_Return)
|
||||
|
||||
assert blocker.args == [test_username.strip(), test_password]
|
||||
assert login_dialog.password.text() == "" # Password should be cleared after emitting
|
||||
Reference in New Issue
Block a user