Skip to content

Commit d99d5e1

Browse files
committed
feat: add feedback dialog
1 parent 402c721 commit d99d5e1

4 files changed

Lines changed: 586 additions & 0 deletions

File tree

bec_widgets/widgets/containers/main_window/main_window.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import os
44
from typing import TYPE_CHECKING
55

6+
from bec_lib import messages
67
from bec_lib.endpoints import MessageEndpoints
78
from qtpy.QtCore import QEvent, QSize, Qt, QTimer
89
from qtpy.QtGui import QAction, QActionGroup, QIcon
@@ -31,6 +32,7 @@
3132
from bec_widgets.widgets.containers.main_window.addons.scroll_label import ScrollLabel
3233
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
3334
from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import ScanProgressBar
35+
from bec_widgets.widgets.utility.feedback_dialog.feedback_dialog import FeedbackDialog
3436

3537
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
3638

@@ -342,6 +344,34 @@ def _setup_menu_bar(self):
342344
help_menu.addAction(widgets_docs)
343345
help_menu.addAction(bug_report)
344346

347+
# Add separator before feedback
348+
help_menu.addSeparator()
349+
350+
# Feedback action
351+
feedback_icon = QApplication.style().standardIcon(
352+
QStyle.StandardPixmap.SP_MessageBoxQuestion
353+
)
354+
feedback_action = QAction("Feedback", self)
355+
feedback_action.setIcon(feedback_icon)
356+
feedback_action.triggered.connect(self._show_feedback_dialog)
357+
help_menu.addAction(feedback_action)
358+
359+
def _show_feedback_dialog(self):
360+
"""Show the feedback dialog and handle the submitted feedback."""
361+
dialog = FeedbackDialog(self)
362+
363+
def on_feedback_submitted(rating: int, comment: str, email: str):
364+
rating = max(1, min(rating, 5)) # Ensure rating is between 1 and 5
365+
username = os.getlogin()
366+
367+
message = messages.FeedbackMessage(
368+
feedback=comment, rating=rating, contact=email, username=username
369+
)
370+
self.bec_dispatcher.client.connector.send(MessageEndpoints.submit_feedback(), message)
371+
372+
dialog.feedback_submitted.connect(on_feedback_submitted)
373+
dialog.exec()
374+
345375
################################################################################
346376
# Status Bar Addons
347377
################################################################################

bec_widgets/widgets/utility/feedback_dialog/__init__.py

Whitespace-only changes.
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
from qtpy.QtCore import Qt, Signal
2+
from qtpy.QtGui import QColor, QFont
3+
from qtpy.QtWidgets import (
4+
QApplication,
5+
QDialog,
6+
QHBoxLayout,
7+
QLabel,
8+
QLineEdit,
9+
QPushButton,
10+
QTextEdit,
11+
QVBoxLayout,
12+
QWidget,
13+
)
14+
15+
from bec_widgets.utils.error_popups import SafeConnect, SafeSlot
16+
17+
18+
class StarRating(QWidget):
19+
"""
20+
A star rating widget that allows users to rate from 1 to 5 stars.
21+
"""
22+
23+
rating_changed = Signal(int)
24+
25+
def __init__(self, parent=None):
26+
super().__init__(parent)
27+
self._rating = 0
28+
self._hovered_star = 0
29+
self._star_buttons = []
30+
31+
# Get theme colors
32+
theme = getattr(QApplication.instance(), "theme", None)
33+
if theme:
34+
SafeConnect(self, theme.theme_changed, self._update_theme_colors)
35+
self._update_theme_colors()
36+
37+
# Enable mouse tracking to handle hover across the entire widget
38+
self.setMouseTracking(True)
39+
40+
layout = QHBoxLayout()
41+
layout.setContentsMargins(0, 0, 0, 0)
42+
layout.setSpacing(5)
43+
44+
for i in range(5):
45+
btn = QPushButton("★")
46+
btn.setFixedSize(30, 30)
47+
btn.setFlat(True)
48+
btn.setCursor(Qt.CursorShape.PointingHandCursor)
49+
btn.clicked.connect(lambda checked=False, idx=i + 1: self._set_rating(idx))
50+
layout.addWidget(btn)
51+
self._star_buttons.append(btn)
52+
53+
self.setLayout(layout)
54+
self._update_display()
55+
56+
@SafeSlot(str)
57+
def _update_theme_colors(self, _theme: str | None = None):
58+
"""Update colors based on theme."""
59+
theme = getattr(QApplication.instance(), "theme", None)
60+
colors = theme.colors if theme else {}
61+
62+
self._inactive_color = colors.get("SEPARATOR", QColor(200, 200, 200))
63+
self._active_color = colors.get("ACCENT_WARNING", QColor(255, 193, 7))
64+
65+
# Update display if already initialized
66+
if hasattr(self, "_star_buttons") and self._star_buttons:
67+
self._update_display()
68+
69+
def _set_rating(self, rating: int):
70+
"""Set the rating and emit the signal."""
71+
if self._rating != rating:
72+
self._rating = rating
73+
self.rating_changed.emit(rating)
74+
self._update_display()
75+
76+
def mouseMoveEvent(self, event):
77+
"""Handle mouse movement to update hovered star."""
78+
# Calculate which star is being hovered based on mouse position
79+
x_pos = event.pos().x()
80+
star_idx = 0
81+
82+
# Find which star region we're in (including gaps between stars)
83+
for i, btn in enumerate(self._star_buttons):
84+
btn_geometry = btn.geometry()
85+
# If we're to the right of this button's left edge, this is the current star
86+
# (including the gap before the next button)
87+
if x_pos >= btn_geometry.left():
88+
star_idx = i + 1
89+
else:
90+
break
91+
92+
if star_idx != self._hovered_star:
93+
self._hovered_star = star_idx
94+
self._update_display()
95+
96+
super().mouseMoveEvent(event)
97+
98+
def leaveEvent(self, event):
99+
"""Handle mouse leaving the widget."""
100+
self._hovered_star = 0
101+
self._update_display()
102+
super().leaveEvent(event)
103+
104+
def _update_display(self):
105+
"""Update the visual display of stars."""
106+
display_rating = self._hovered_star if self._hovered_star > 0 else self._rating
107+
inactive_color_name = self._inactive_color.name()
108+
active_color_name = self._active_color.name()
109+
110+
for i, btn in enumerate(self._star_buttons):
111+
if i < display_rating:
112+
btn.setStyleSheet(
113+
f"""
114+
QPushButton {{
115+
border: none;
116+
background: transparent;
117+
font-size: 24px;
118+
color: {active_color_name};
119+
}}
120+
"""
121+
)
122+
else:
123+
btn.setStyleSheet(
124+
f"""
125+
QPushButton {{
126+
border: none;
127+
background: transparent;
128+
font-size: 24px;
129+
color: {inactive_color_name};
130+
}}
131+
QPushButton:hover {{
132+
color: {active_color_name};
133+
}}
134+
"""
135+
)
136+
137+
def rating(self) -> int:
138+
"""Get the current rating."""
139+
return self._rating
140+
141+
def set_rating(self, rating: int):
142+
"""Set the rating programmatically."""
143+
if 0 <= rating <= 5:
144+
self._set_rating(rating)
145+
146+
147+
class FeedbackDialog(QDialog):
148+
"""
149+
A feedback dialog widget containing a comment field, star rating, and optional email field.
150+
151+
Signals:
152+
feedbackSubmitted: Emitted when feedback is submitted (rating: int, comment: str, email: str)
153+
"""
154+
155+
feedback_submitted = Signal(int, str, str)
156+
ICON_NAME = "feedback"
157+
PLUGIN = True
158+
159+
def __init__(self, parent=None):
160+
super().__init__(parent)
161+
self.setWindowTitle("Feedback")
162+
self.setModal(True)
163+
self.setMinimumWidth(400)
164+
self.setMinimumHeight(300)
165+
166+
self._setup_ui()
167+
168+
def _setup_ui(self):
169+
"""Set up the user interface."""
170+
layout = QVBoxLayout()
171+
layout.setSpacing(15)
172+
173+
# Title
174+
title_label = QLabel("We'd love to hear your feedback!")
175+
title_font = QFont()
176+
title_font.setPointSize(12)
177+
title_font.setBold(True)
178+
title_label.setFont(title_font)
179+
layout.addWidget(title_label)
180+
181+
# Star rating section
182+
rating_layout = QVBoxLayout()
183+
rating_label = QLabel("Rating:")
184+
rating_layout.addWidget(rating_label)
185+
186+
self._star_rating = StarRating()
187+
rating_layout.addWidget(self._star_rating)
188+
layout.addLayout(rating_layout)
189+
190+
# Comment section
191+
comment_label = QLabel("Comments:")
192+
layout.addWidget(comment_label)
193+
194+
self._comment_field = QTextEdit()
195+
self._comment_field.setPlaceholderText("Please share your thoughts...")
196+
self._comment_field.setMaximumHeight(150)
197+
layout.addWidget(self._comment_field)
198+
199+
# Email section (optional)
200+
email_label = QLabel("Email (optional, for follow-up):")
201+
layout.addWidget(email_label)
202+
203+
self._email_field = QLineEdit()
204+
self._email_field.setPlaceholderText("your.email@example.com")
205+
layout.addWidget(self._email_field)
206+
207+
# Buttons
208+
button_layout = QHBoxLayout()
209+
button_layout.addStretch()
210+
211+
self._cancel_button = QPushButton("Cancel")
212+
self._cancel_button.clicked.connect(self.reject)
213+
button_layout.addWidget(self._cancel_button)
214+
215+
self._submit_button = QPushButton("Submit")
216+
self._submit_button.setDefault(True)
217+
self._submit_button.clicked.connect(self._on_submit)
218+
button_layout.addWidget(self._submit_button)
219+
220+
layout.addLayout(button_layout)
221+
222+
self.setLayout(layout)
223+
224+
def _on_submit(self):
225+
"""Handle submit button click."""
226+
rating = self._star_rating.rating()
227+
comment = self._comment_field.toPlainText().strip()
228+
email = self._email_field.text().strip()
229+
230+
# Emit the feedback signal
231+
self.feedback_submitted.emit(rating, comment, email)
232+
233+
# Accept the dialog
234+
self.accept()
235+
236+
def get_feedback(self) -> tuple[int, str, str]:
237+
"""
238+
Get the current feedback values.
239+
240+
Returns:
241+
tuple: (rating, comment, email)
242+
"""
243+
return (
244+
self._star_rating.rating(),
245+
self._comment_field.toPlainText().strip(),
246+
self._email_field.text().strip(),
247+
)
248+
249+
def set_rating(self, rating: int):
250+
"""Set the star rating."""
251+
self._star_rating.set_rating(rating)
252+
253+
def set_comment(self, comment: str):
254+
"""Set the comment text."""
255+
self._comment_field.setPlainText(comment)
256+
257+
def set_email(self, email: str):
258+
"""Set the email text."""
259+
self._email_field.setText(email)
260+
261+
@staticmethod
262+
def show_feedback_dialog(parent=None) -> tuple[int, str, str] | None:
263+
"""
264+
Show the feedback dialog and return the feedback if submitted.
265+
266+
Args:
267+
parent: Parent widget
268+
269+
Returns:
270+
tuple: (rating, comment, email) if submitted, None if cancelled
271+
"""
272+
dialog = FeedbackDialog(parent)
273+
if dialog.exec() == QDialog.DialogCode.Accepted:
274+
return dialog.get_feedback()
275+
return None
276+
277+
278+
if __name__ == "__main__": # pragma: no cover
279+
import sys
280+
281+
from bec_widgets.utils.colors import apply_theme
282+
283+
app = QApplication(sys.argv)
284+
apply_theme("dark")
285+
dialog = FeedbackDialog()
286+
287+
def on_feedback(rating, comment, email):
288+
print(f"Rating: {rating}")
289+
print(f"Comment: {comment}")
290+
print(f"Email: {email}")
291+
292+
dialog.feedback_submitted.connect(on_feedback)
293+
dialog.exec()
294+
sys.exit(app.exec())

0 commit comments

Comments
 (0)