diff --git a/bec_widgets/widgets/containers/main_window/addons/hover_widget.py b/bec_widgets/widgets/containers/main_window/addons/hover_widget.py new file mode 100644 index 00000000..2779cb1c --- /dev/null +++ b/bec_widgets/widgets/containers/main_window/addons/hover_widget.py @@ -0,0 +1,115 @@ +import sys + +from qtpy.QtCore import QPoint, Qt +from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QProgressBar, QVBoxLayout, QWidget + + +class WidgetTooltip(QWidget): + """Frameless, always-on-top window that behaves like a tooltip.""" + + def __init__(self, content: QWidget) -> None: + super().__init__(None, Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint) + self.setAttribute(Qt.WA_ShowWithoutActivating) + self.setMouseTracking(True) + self.content = content + + layout = QVBoxLayout(self) + layout.setContentsMargins(6, 6, 6, 6) + layout.addWidget(self.content) + self.adjustSize() + + def leaveEvent(self, _event) -> None: + self.hide() + + def show_above(self, global_pos: QPoint, offset: int = 8) -> None: + self.adjustSize() + screen = QApplication.screenAt(global_pos) or QApplication.primaryScreen() + screen_geo = screen.availableGeometry() + geom = self.geometry() + + x = global_pos.x() - geom.width() // 2 + y = global_pos.y() - geom.height() - offset + + x = max(screen_geo.left(), min(x, screen_geo.right() - geom.width())) + y = max(screen_geo.top(), min(y, screen_geo.bottom() - geom.height())) + + self.move(x, y) + self.show() + + +class HoverWidget(QWidget): + + def __init__(self, parent: QWidget | None = None, *, simple: QWidget, full: QWidget): + super().__init__(parent) + self._simple = simple + self._full = full + self._full.setVisible(False) + self._tooltip = None + + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(simple) + + def enterEvent(self, event): + # suppress empty-label tooltips for labels + if isinstance(self._full, QLabel) and not self._full.text(): + return + + if self._tooltip is None: # first time only + self._tooltip = WidgetTooltip(self._full) + self._full.setVisible(True) + + centre = self.mapToGlobal(self.rect().center()) + self._tooltip.show_above(centre) + super().enterEvent(event) + + def leaveEvent(self, event): + if self._tooltip and self._tooltip.isVisible(): + self._tooltip.hide() + super().leaveEvent(event) + + def close(self): + if self._tooltip: + self._tooltip.close() + self._tooltip.deleteLater() + self._tooltip = None + super().close() + + +################################################################################ +# Demo +# Just a simple example to show how the HoverWidget can be used to display +# a tooltip with a full widget inside (two different widgets are used +# for the simple and full versions). +################################################################################ + + +class DemoSimpleWidget(QLabel): # pragma: no cover + """A simple widget to be used as a trigger for the tooltip.""" + + def __init__(self) -> None: + super().__init__() + self.setText("Hover me for a preview!") + + +class DemoFullWidget(QProgressBar): # pragma: no cover + """A full widget to be shown in the tooltip.""" + + def __init__(self) -> None: + super().__init__() + self.setRange(0, 100) + self.setValue(75) + self.setFixedWidth(320) + self.setFixedHeight(30) + + +if __name__ == "__main__": # pragma: no cover + app = QApplication(sys.argv) + + window = QWidget() + window.layout = QHBoxLayout(window) + hover_widget = HoverWidget(simple=DemoSimpleWidget(), full=DemoFullWidget()) + window.layout.addWidget(hover_widget) + window.show() + + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index 596781f4..9ec5e96f 100644 --- a/bec_widgets/widgets/containers/main_window/main_window.py +++ b/bec_widgets/widgets/containers/main_window/main_window.py @@ -3,15 +3,7 @@ from __future__ import annotations import os from bec_lib.endpoints import MessageEndpoints -from qtpy.QtCore import ( - QAbstractAnimation, - QEasingCurve, - QEvent, - QPropertyAnimation, - QSize, - Qt, - QTimer, -) +from qtpy.QtCore import QEasingCurve, QEvent, QPropertyAnimation, QSize, Qt, QTimer from qtpy.QtGui import QAction, QActionGroup, QIcon from qtpy.QtWidgets import ( QApplication, @@ -30,6 +22,7 @@ from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.widget_io import WidgetHierarchy +from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget 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 @@ -96,33 +89,60 @@ class BECMainWindow(BECWidget, QMainWindow): self._add_separator() # Centre: Client‑info label (stretch=1 so it expands) - self._client_info_label = ScrollLabel() - self._client_info_label.setAlignment( - Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter - ) - self.status_bar.addWidget(self._client_info_label, 1) - - # Timer to automatically clear client messages once they expire - self._client_info_expire_timer = QTimer(self) - self._client_info_expire_timer.setSingleShot(True) - self._client_info_expire_timer.timeout.connect(lambda: self._client_info_label.setText("")) + self._add_client_info_label() # Add scan_progress bar with display logic self._add_scan_progress_bar() + ################################################################################ + # Client message status bar widget helpers + + def _add_client_info_label(self): + """ + Add a client info label to the status bar. + This label will display messages from the BEC dispatcher. + """ + + # Scroll label for client info in Status Bar + self._client_info_label = ScrollLabel(self) + self._client_info_label.setAlignment( + Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter + ) + # Full label used in the hover widget + self._client_info_label_full = QLabel(self) + self._client_info_label_full.setAlignment( + Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter + ) + # Hover widget to show the full client info label + self._client_info_hover = HoverWidget( + self, simple=self._client_info_label, full=self._client_info_label_full + ) + self.status_bar.addWidget(self._client_info_hover, 1) + + # Timer to automatically clear client messages once they expire + self._client_info_expire_timer = QTimer(self) + self._client_info_expire_timer.setSingleShot(True) + self._client_info_expire_timer.timeout.connect(lambda: self._client_info_label.setText("")) + self._client_info_expire_timer.timeout.connect( + lambda: self._client_info_label_full.setText("") + ) + ################################################################################ # Progress‑bar helpers def _add_scan_progress_bar(self): - # --- Progress bar ------------------------------------------------- - # Scan progress bar minimalistic design setup - self._scan_progress_bar = ScanProgressBar(self, one_line_design=True) - self._scan_progress_bar.show_elapsed_time = False - self._scan_progress_bar.show_remaining_time = False - self._scan_progress_bar.show_source_label = False - self._scan_progress_bar.progressbar.label_template = "" - self._scan_progress_bar.progressbar.setFixedHeight(8) - self._scan_progress_bar.progressbar.setFixedWidth(80) + # Setting HoverWidget for the scan progress bar - minimal and full version + self._scan_progress_bar_simple = ScanProgressBar(self, one_line_design=True) + self._scan_progress_bar_simple.show_elapsed_time = False + self._scan_progress_bar_simple.show_remaining_time = False + self._scan_progress_bar_simple.show_source_label = False + self._scan_progress_bar_simple.progressbar.label_template = "" + self._scan_progress_bar_simple.progressbar.setFixedHeight(8) + self._scan_progress_bar_simple.progressbar.setFixedWidth(80) + self._scan_progress_bar_full = ScanProgressBar(self) + self._scan_progress_hover = HoverWidget( + self, simple=self._scan_progress_bar_simple, full=self._scan_progress_bar_full + ) # Bundle the progress bar with a separator separator = self._add_separator(separate_object=True) @@ -133,7 +153,7 @@ class BECMainWindow(BECWidget, QMainWindow): self._scan_progress_bar_with_separator.layout.setContentsMargins(0, 0, 0, 0) self._scan_progress_bar_with_separator.layout.setSpacing(0) self._scan_progress_bar_with_separator.layout.addWidget(separator) - self._scan_progress_bar_with_separator.layout.addWidget(self._scan_progress_bar) + self._scan_progress_bar_with_separator.layout.addWidget(self._scan_progress_hover) # Set Size self._scan_progress_bar_target_width = self.SCAN_PROGRESS_WIDTH @@ -152,8 +172,8 @@ class BECMainWindow(BECWidget, QMainWindow): self._scan_progress_hide_timer.timeout.connect(self._animate_hide_scan_progress_bar) # Show / hide behaviour - self._scan_progress_bar.progress_started.connect(self._show_scan_progress_bar) - self._scan_progress_bar.progress_finished.connect(self._delay_hide_scan_progress_bar) + self._scan_progress_bar_simple.progress_started.connect(self._show_scan_progress_bar) + self._scan_progress_bar_simple.progress_finished.connect(self._delay_hide_scan_progress_bar) def _show_scan_progress_bar(self): if self._scan_progress_hide_timer.isActive(): @@ -342,10 +362,10 @@ class BECMainWindow(BECWidget, QMainWindow): msg(dict): The message to display, should contain: meta(dict): Metadata about the message, usually empty. """ - # self._client_info_label.setText("") message = msg.get("message", "") expiration = msg.get("expire", 0) # 0 → never expire self._client_info_label.setText(message) + self._client_info_label_full.setText(message) # Restart the expiration timer if necessary if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive(): @@ -393,10 +413,20 @@ class BECMainWindow(BECWidget, QMainWindow): if hasattr(self, "_scan_progress_hide_timer") and self._scan_progress_hide_timer.isActive(): self._scan_progress_hide_timer.stop() + ######################################## # Status bar widgets cleanup + + # Client info label cleanup self._client_info_label.cleanup() - self._scan_progress_bar.close() - self._scan_progress_bar.deleteLater() + self._client_info_hover.close() + self._client_info_hover.deleteLater() + # Scan progress bar cleanup + self._scan_progress_bar_simple.close() + self._scan_progress_bar_simple.deleteLater() + self._scan_progress_bar_full.close() + self._scan_progress_bar_full.deleteLater() + self._scan_progress_hover.close() + self._scan_progress_hover.deleteLater() super().cleanup() diff --git a/tests/unit_tests/test_main_widnow.py b/tests/unit_tests/test_main_widnow.py index ed92935b..fbbce59e 100644 --- a/tests/unit_tests/test_main_widnow.py +++ b/tests/unit_tests/test_main_widnow.py @@ -1,8 +1,14 @@ import webbrowser import pytest -from qtpy.QtWidgets import QFrame +from qtpy.QtCore import QEvent, QPoint, QPointF +from qtpy.QtGui import QEnterEvent +from qtpy.QtWidgets import QApplication, QFrame, QLabel +from bec_widgets.widgets.containers.main_window.addons.hover_widget import ( + HoverWidget, + WidgetTooltip, +) 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.containers.main_window.main_window import BECMainWindow @@ -228,3 +234,60 @@ def test_scan_progress_bar_hide_animation(qtbot, bec_main_window): qtbot.waitUntil(lambda: container.maximumWidth() == 0, timeout=2000) assert container.maximumWidth() == 0 + + +################################################################# +# Tests for hover widget and tooltip behaviour + + +def test_hover_widget_tooltip(qtbot): + """ + After a HoverWidget is closed, its WidgetTooltip must be gone. + """ + simple = QLabel("Hover me") + full = QLabel("Full details") + hover = create_widget(qtbot, HoverWidget, simple=simple, full=full) + + assert hover._simple is simple + assert hover._full is full + assert hover._tooltip is None + + +def test_widget_tooltip_show_and_hide(qtbot): + """ + WidgetTooltip should appear when show_above is called and hide on Leave. + """ + full_lbl = QLabel("Standalone tooltip content") + tooltip = create_widget(qtbot, WidgetTooltip, content=full_lbl) + + # Show above an arbitrary point + pos = QPoint(200, 200) + tooltip.show_above(pos) + assert tooltip.isVisible() + + # Send a synthetic Leave event + QApplication.sendEvent(tooltip, QEvent(QEvent.Leave)) + qtbot.waitUntil(lambda: not tooltip.isVisible(), timeout=500) + assert not tooltip.isVisible() + + +def test_hover_widget_mouse_events(qtbot): + """ + Verify that HoverWidget responds correctly to Enter, MouseMove, and Leave + events, keeping the tooltip visible only while the pointer is inside. + """ + simple = QLabel("Hover‑target") + full = QLabel("Full‑view") + hover = create_widget(qtbot, HoverWidget, simple=simple, full=full) + + local = QPointF(hover.rect().center()) # inside widget + scene = QPointF(hover.mapTo(hover.window(), local.toPoint())) + global_ = QPointF(hover.mapToGlobal(local.toPoint())) + + enter_event = QEnterEvent(local, scene, global_) + hover.enterEvent(event=enter_event) + qtbot.wait(200) + + assert hover._tooltip is not None + assert hover._tooltip.isVisible() + assert hover._tooltip.content is full