0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-13 19:21:50 +02:00

feat(hover_widget) widget enables to display different widget upon hover; applied to scan progress and client info message in status bar of BECMainWindow

This commit is contained in:
2025-06-20 20:19:30 +02:00
committed by Jan Wyzula
parent 5a137d1219
commit 6421050116
3 changed files with 243 additions and 35 deletions

View File

@ -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_())

View File

@ -3,15 +3,7 @@ from __future__ import annotations
import os import os
from bec_lib.endpoints import MessageEndpoints from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import ( from qtpy.QtCore import QEasingCurve, QEvent, QPropertyAnimation, QSize, Qt, QTimer
QAbstractAnimation,
QEasingCurve,
QEvent,
QPropertyAnimation,
QSize,
Qt,
QTimer,
)
from qtpy.QtGui import QAction, QActionGroup, QIcon from qtpy.QtGui import QAction, QActionGroup, QIcon
from qtpy.QtWidgets import ( from qtpy.QtWidgets import (
QApplication, 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.colors import apply_theme
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.widget_io import WidgetHierarchy 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.scroll_label import ScrollLabel
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin 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.progress.scan_progressbar.scan_progressbar import ScanProgressBar
@ -96,33 +89,60 @@ class BECMainWindow(BECWidget, QMainWindow):
self._add_separator() self._add_separator()
# Centre: Clientinfo label (stretch=1 so it expands) # Centre: Clientinfo label (stretch=1 so it expands)
self._client_info_label = ScrollLabel() self._add_client_info_label()
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(""))
# Add scan_progress bar with display logic # Add scan_progress bar with display logic
self._add_scan_progress_bar() 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("")
)
################################################################################ ################################################################################
# Progressbar helpers # Progressbar helpers
def _add_scan_progress_bar(self): def _add_scan_progress_bar(self):
# --- Progress bar ------------------------------------------------- # Setting HoverWidget for the scan progress bar - minimal and full version
# Scan progress bar minimalistic design setup self._scan_progress_bar_simple = ScanProgressBar(self, one_line_design=True)
self._scan_progress_bar = ScanProgressBar(self, one_line_design=True) self._scan_progress_bar_simple.show_elapsed_time = False
self._scan_progress_bar.show_elapsed_time = False self._scan_progress_bar_simple.show_remaining_time = False
self._scan_progress_bar.show_remaining_time = False self._scan_progress_bar_simple.show_source_label = False
self._scan_progress_bar.show_source_label = False self._scan_progress_bar_simple.progressbar.label_template = ""
self._scan_progress_bar.progressbar.label_template = "" self._scan_progress_bar_simple.progressbar.setFixedHeight(8)
self._scan_progress_bar.progressbar.setFixedHeight(8) self._scan_progress_bar_simple.progressbar.setFixedWidth(80)
self._scan_progress_bar.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 # Bundle the progress bar with a separator
separator = self._add_separator(separate_object=True) 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.setContentsMargins(0, 0, 0, 0)
self._scan_progress_bar_with_separator.layout.setSpacing(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(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 # Set Size
self._scan_progress_bar_target_width = self.SCAN_PROGRESS_WIDTH 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) self._scan_progress_hide_timer.timeout.connect(self._animate_hide_scan_progress_bar)
# Show / hide behaviour # Show / hide behaviour
self._scan_progress_bar.progress_started.connect(self._show_scan_progress_bar) self._scan_progress_bar_simple.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_finished.connect(self._delay_hide_scan_progress_bar)
def _show_scan_progress_bar(self): def _show_scan_progress_bar(self):
if self._scan_progress_hide_timer.isActive(): if self._scan_progress_hide_timer.isActive():
@ -342,10 +362,10 @@ class BECMainWindow(BECWidget, QMainWindow):
msg(dict): The message to display, should contain: msg(dict): The message to display, should contain:
meta(dict): Metadata about the message, usually empty. meta(dict): Metadata about the message, usually empty.
""" """
# self._client_info_label.setText("")
message = msg.get("message", "") message = msg.get("message", "")
expiration = msg.get("expire", 0) # 0 → never expire expiration = msg.get("expire", 0) # 0 → never expire
self._client_info_label.setText(message) self._client_info_label.setText(message)
self._client_info_label_full.setText(message)
# Restart the expiration timer if necessary # Restart the expiration timer if necessary
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive(): 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(): if hasattr(self, "_scan_progress_hide_timer") and self._scan_progress_hide_timer.isActive():
self._scan_progress_hide_timer.stop() self._scan_progress_hide_timer.stop()
########################################
# Status bar widgets cleanup # Status bar widgets cleanup
# Client info label cleanup
self._client_info_label.cleanup() self._client_info_label.cleanup()
self._scan_progress_bar.close() self._client_info_hover.close()
self._scan_progress_bar.deleteLater() 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() super().cleanup()

View File

@ -1,8 +1,14 @@
import webbrowser import webbrowser
import pytest 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.scroll_label import ScrollLabel
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow 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) qtbot.waitUntil(lambda: container.maximumWidth() == 0, timeout=2000)
assert container.maximumWidth() == 0 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("Hovertarget")
full = QLabel("Fullview")
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