1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-03-12 03:37:56 +01:00

wip migrated hover gradient from bec widgets - removed completely also with the round frame nonsense

This commit is contained in:
2025-11-25 13:49:49 +01:00
parent 644e4ecae0
commit 37c76509e7
7 changed files with 58 additions and 333 deletions

View File

@@ -5,29 +5,31 @@ import xml.etree.ElementTree as ET
from typing import TYPE_CHECKING, Callable
from bec_lib.logger import bec_logger
from bec_qthemes import enable_hover_gradient
from qtpy.QtCore import Qt, Signal # type: ignore
from qtpy.QtGui import QFontMetrics, QPainter, QPainterPath, QPixmap
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QFileDialog,
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QSpacerItem,
QVBoxLayout,
QWidget,
)
import bec_widgets
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.hover_gradient import enable_hover_gradient
from bec_widgets.utils.name_utils import pascal_to_snake
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
@@ -44,7 +46,7 @@ logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class LaunchTile(RoundedFrame):
class LaunchTile(QFrame):
DEFAULT_SIZE = (250, 300)
open_signal = Signal()
@@ -59,7 +61,12 @@ class LaunchTile(RoundedFrame):
tile_size: tuple[int, int] | None = None,
gradient: list[str] | None = None,
):
super().__init__(parent=parent, orientation="vertical")
super().__init__(parent=parent)
self.setProperty("skip_settings", True)
self.setProperty("variant", "tile")
self.setAttribute(Qt.WA_StyledBackground, True)
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(5, 5, 5, 5)
# Provide a perinstance TILE_SIZE so the class can compute layout
if tile_size is None:
@@ -158,6 +165,10 @@ class LaunchTile(RoundedFrame):
if gradient is not None:
enable_hover_gradient(self, colours=gradient)
def apply_theme(self, theme: str):
"""Allow tiles to be theme-aware without custom styling logic."""
self.update()
def _fit_label_to_width(self, label: QLabel, max_width: int, min_pt: int = 10):
"""
Fit the label text to the specified maximum width by adjusting the font size.
@@ -595,6 +606,7 @@ if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
apply_theme("dark")
launcher = LaunchWindow()
launcher.show()
sys.exit(app.exec())

View File

@@ -1,143 +0,0 @@
import re
import types
from qtpy import QtCore, QtWidgets, QtGui
class _HoverGradientFilter(QtCore.QObject):
"""Tracks hover/press state for a given widget and forces repaints."""
def __init__(self, target):
super().__init__(target)
self._t = target
target.destroyed.connect(self._on_dead)
self._propagate_mouse_tracking(target)
@QtCore.Slot()
def _on_dead(self):
"""Target widget is gone → remove the filter."""
self._t = None # guard future calls
self.deleteLater()
# ensure all descendants forward move events
def _propagate_mouse_tracking(self, w):
for c in w.findChildren(QtWidgets.QWidget):
c.setMouseTracking(True)
c.installEventFilter(self)
self._propagate_mouse_tracking(c)
# core event handling
def eventFilter(self, watched, ev):
if self._t is None: # already cleaned up
return False
t = self._t
typ = ev.type()
if typ == QtCore.QEvent.MouseMove:
inside = t.rect().contains(t.mapFromGlobal(ev.globalPos()))
if inside:
p = t.mapFromGlobal(ev.globalPos())
if p != getattr(t, "_hg_pos", QtCore.QPoint()):
t._hg_pos = p
t.update()
t._hg_hover = True
elif getattr(t, "_hg_hover", False):
t._hg_hover = False
t.update()
elif typ == QtCore.QEvent.Enter:
t._hg_hover, t._hg_pos = True, ev.pos()
t.update()
elif typ == QtCore.QEvent.Leave:
t._hg_hover = False
t.update()
elif typ == QtCore.QEvent.MouseButtonPress:
t._hg_pressed = True
t.update()
elif typ == QtCore.QEvent.MouseButtonRelease:
t._hg_pressed = False
t.update()
return super().eventFilter(watched, ev)
# ─────────────────────────── painter helper ─────────────────────────────
def _draw_hover_gradient(widget, painter, path, opacity):
if not getattr(widget, "_hg_hover", False) or widget._hg_pos.x() < 0:
return
pressed = getattr(widget, "_hg_pressed", False)
cols = getattr(widget, "_hg_cols", [QtGui.QColor("#ffffff")])
accent = cols[0]
r = max(widget.width(), widget.height()) * (0.6 if pressed else 0.9)
grad = QtGui.QRadialGradient(widget._hg_pos, r)
centre = QtGui.QColor(accent)
centre.setAlpha(opacity if pressed else opacity * 0.6) # more opaque when pressed
grad.setColorAt(0.0, centre)
edge = cols[1] if len(cols) > 1 else QtCore.Qt.transparent
grad.setColorAt(1.0, edge)
painter.fillPath(path, grad)
# ─────────────────────────── public API ────────────────────────────────
def enable_hover_gradient(frame: QtWidgets.QFrame, colours=None, opacity=1.0):
"""
Inject a radial hover-gradient glow into *any* QFrame instance.
Parameters
----------
frame : QFrame
The widget to enhance.
colours : str | list[str] | None
One colour → accent→transparent. Two colours → accent→edge.
"""
if getattr(frame, "_hg_enabled", False): # hover gradient injected attribute
return # already done
opacity = 255 * opacity
# normalise colours
if colours is None:
colours = ["#ffffff"]
if isinstance(colours, str):
colours = [colours]
# state variables stored directly on the frame
frame._hg_enabled = True
frame._hg_cols = [QtGui.QColor(c) for c in colours]
frame._hg_hover = False
frame._hg_pressed = False
frame._hg_pos = QtCore.QPoint(-1, -1)
# 1) patch paintEvent
orig_paint = frame.paintEvent
def patched_paint(self, ev):
orig_paint(ev)
painter = QtGui.QPainter(self)
painter.setRenderHint(QtGui.QPainter.Antialiasing)
rad = 0 # default radius
m = re.search(r"border-radius\s*:\s*([0-9]+)", self.styleSheet())
if m:
rad = int(m.group(1))
path = QtGui.QPainterPath()
if rad > 0:
path.addRoundedRect(self.rect().adjusted(0, 0, -1, -1), rad, rad)
else:
path.addRect(self.rect().adjusted(0, 0, -1, -1))
_draw_hover_gradient(self, painter, path, opacity)
painter.end()
frame.paintEvent = types.MethodType(patched_paint, frame)
frame._hg_orig_paint = orig_paint
# 2) install tracking filter
filt = _HoverGradientFilter(frame)
frame._hg_filter = filt
frame.installEventFilter(filt)
frame.setAttribute(QtCore.Qt.WA_StyledBackground, True)
frame.setMouseTracking(True)

View File

@@ -1,126 +0,0 @@
import pyqtgraph as pg
from qtpy.QtCore import Property, Qt
from qtpy.QtWidgets import QApplication, QFrame, QHBoxLayout, QVBoxLayout, QWidget
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
class RoundedFrame(QFrame):
# TODO this should be removed completely in favor of QSS styling, no time now
"""
A custom QFrame with rounded corners and optional theme updates.
The frame can contain any QWidget, however it is mainly designed to wrap PlotWidgets to provide a consistent look and feel with other BEC Widgets.
"""
def __init__(
self,
parent=None,
content_widget: QWidget = None,
background_color: str = None,
orientation: str = "horizontal",
radius: int = 10,
):
QFrame.__init__(self, parent)
self.background_color = background_color
self._radius = radius
# Apply rounded frame styling
self.setProperty("skip_settings", True)
self.setObjectName("roundedFrame")
# Ensure QSS can paint background/border on this widget
self.setAttribute(Qt.WA_StyledBackground, True)
# Create a layout for the frame
if orientation == "vertical":
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(5, 5, 5, 5)
else:
self.layout = QHBoxLayout(self)
self.layout.setContentsMargins(5, 5, 5, 5) # Set 5px margin
# Add the content widget to the layout
if content_widget:
self.layout.addWidget(content_widget)
# Store reference to the content widget
self.content_widget = content_widget
# Automatically apply initial styles to the GraphicalLayoutWidget if applicable
self.apply_plot_widget_style()
self.update_style()
def apply_theme(self, theme: str):
"""Deprecated: RoundedFrame no longer handles theme; styling is QSS-driven."""
self.update_style()
@Property(int)
def radius(self):
"""Radius of the rounded corners."""
return self._radius
@radius.setter
def radius(self, value: int):
self._radius = value
self.update_style()
def update_style(self):
"""
Update the style of the frame based on the background color.
"""
self.setStyleSheet(
f"""
QFrame#roundedFrame {{
border-radius: {self._radius}px;
}}
"""
)
self.apply_plot_widget_style()
def apply_plot_widget_style(self, border: str = "none"):
"""
Let QSS/pyqtgraph handle plot styling; avoid overriding here.
"""
if isinstance(self.content_widget, pg.GraphicsLayoutWidget):
self.content_widget.setStyleSheet("")
class ExampleApp(QWidget): # pragma: no cover
def __init__(self):
super().__init__()
self.setWindowTitle("Rounded Plots Example")
# Main layout
layout = QVBoxLayout(self)
dark_button = DarkModeButton()
# Create PlotWidgets
plot1 = pg.GraphicsLayoutWidget()
plot_item_1 = pg.PlotItem()
plot_item_1.plot([1, 3, 2, 4, 6, 5], pen="r")
plot1.plot_item = plot_item_1
plot2 = pg.GraphicsLayoutWidget()
plot_item_2 = pg.PlotItem()
plot_item_2.plot([1, 2, 4, 8, 16, 32], pen="r")
plot2.plot_item = plot_item_2
# Add to layout (no RoundedFrame wrapper; QSS styles plots)
layout.addWidget(dark_button)
layout.addWidget(plot1)
layout.addWidget(plot2)
self.setLayout(layout)
# Theme flip demo removed; global theming applies automatically
if __name__ == "__main__": # pragma: no cover
app = QApplication([])
window = ExampleApp()
window.show()
app.exec()

View File

@@ -1,16 +1,24 @@
import pyqtgraph as pg
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QFrame, QVBoxLayout
from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.widgets.plots.plot_base import BECViewBox
class ImageROIPlot(RoundedFrame):
class ImageROIPlot(QFrame):
"""
A widget for displaying an image with a region of interest (ROI) overlay.
"""
def __init__(self, parent=None):
super().__init__(parent=parent)
self.setAttribute(Qt.WA_StyledBackground, True)
self.setProperty("variant", "plot_background")
self.setProperty("frameless", True)
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(5, 5, 5, 5)
self.layout.setSpacing(0)
self.content_widget = pg.GraphicsLayoutWidget(self)
self.layout.addWidget(self.content_widget)
@@ -27,7 +35,15 @@ class ImageROIPlot(RoundedFrame):
self.curve_color = "k"
for curve in self.plot_item.curves:
curve.setPen(pg.mkPen(self.curve_color, width=3))
super().apply_theme(theme)
self.apply_plot_widget_style()
def apply_plot_widget_style(self, border: str = "none"):
"""Keep pyqtgraph widgets styled by QSS/themes."""
if border != "none":
self.content_widget.setStyleSheet(f"border: {border};")
else:
self.content_widget.setStyleSheet("")
def cleanup_pyqtgraph(self):
"""Cleanup pyqtgraph items."""

View File

@@ -6,14 +6,13 @@ import numpy as np
import pyqtgraph as pg
from bec_lib import bec_logger
from qtpy.QtCore import QPoint, QPointF, Qt, Signal
from qtpy.QtWidgets import QHBoxLayout, QLabel, QMainWindow, QVBoxLayout, QWidget
from qtpy.QtWidgets import QFrame, QHBoxLayout, QLabel, QMainWindow, QVBoxLayout, QWidget
from bec_widgets.utils import ConnectionConfig, Crosshair, EntryValidator
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.fps_counter import FPSCounter
from bec_widgets.utils.plot_indicator_items import BECArrowItem, BECTickItem
from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.utils.side_panel import SidePanel
from bec_widgets.utils.toolbars.performance import PerformanceConnection, performance_bundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
@@ -144,25 +143,43 @@ class PlotBase(BECWidget, QWidget):
self._update_theme(None)
def apply_theme(self, theme: str):
self.round_plot_widget.apply_theme(theme)
self.apply_plot_widget_style()
super().apply_theme(theme)
def _init_ui(self):
self.layout.addWidget(self.layout_manager)
self.round_plot_widget = RoundedFrame(parent=self, content_widget=self.plot_widget)
self.round_plot_widget = QFrame(parent=self)
self.round_plot_widget.setAttribute(Qt.WA_StyledBackground, True)
self.round_plot_widget.setProperty("variant", "plot_background")
self.round_plot_widget.setProperty("frameless", True)
plot_frame_layout = QVBoxLayout(self.round_plot_widget)
plot_frame_layout.setContentsMargins(5, 5, 5, 5)
plot_frame_layout.setSpacing(0)
plot_frame_layout.addWidget(self.plot_widget)
self.layout_manager.add_widget(self.round_plot_widget)
self.layout_manager.add_widget_relative(self.fps_label, self.round_plot_widget, "top")
self.fps_label.hide()
self.layout_manager.add_widget_relative(self.side_panel, self.round_plot_widget, "left")
self.layout_manager.add_widget_relative(self.toolbar, self.fps_label, "top")
self.apply_plot_widget_style()
self.ui_mode = self._ui_mode # to initiate the first time
# PlotItem ViewBox Signals
self.plot_item.vb.sigStateChanged.connect(self.viewbox_state_changed)
def apply_plot_widget_style(self, border: str = "none"):
"""Let theme/QSS style the plot widget; keep custom overrides minimal."""
if not isinstance(self.plot_widget, pg.GraphicsLayoutWidget):
return
if border != "none":
self.plot_widget.setStyleSheet(f"border: {border};")
else:
self.plot_widget.setStyleSheet("")
def _init_toolbar(self):
self.toolbar.add_bundle(performance_bundle(self.toolbar.components))
self.toolbar.add_bundle(plot_export_bundle(self.toolbar.components))

View File

@@ -575,7 +575,7 @@ class Waveform(PlotBase):
self.async_signal_update.emit()
self.sync_signal_update.emit()
self.plot_item.enableAutoRange(x=True)
self.round_plot_widget.apply_plot_widget_style() # To keep the correct theme
self.apply_plot_widget_style() # To keep the correct theme
@SafeProperty(str)
def x_entry(self) -> str | None:
@@ -604,7 +604,7 @@ class Waveform(PlotBase):
self.async_signal_update.emit()
self.sync_signal_update.emit()
self.plot_item.enableAutoRange(x=True)
self.round_plot_widget.apply_plot_widget_style()
self.apply_plot_widget_style()
@SafeProperty(str)
def color_palette(self) -> str:

View File

@@ -1,51 +0,0 @@
import pyqtgraph as pg
import pytest
from bec_widgets.utils.round_frame import RoundedFrame
def cleanup_pyqtgraph(plot_widget):
item = plot_widget.getPlotItem()
item.vb.menu.close()
item.vb.menu.deleteLater()
item.ctrlMenu.close()
item.ctrlMenu.deleteLater()
@pytest.fixture
def basic_rounded_frame(qtbot):
frame = RoundedFrame()
qtbot.addWidget(frame)
qtbot.waitExposed(frame)
yield frame
@pytest.fixture
def plot_rounded_frame(qtbot):
plot_widget = pg.PlotWidget()
plot_widget.plot([0, 1, 2], [2, 1, 0])
frame = RoundedFrame(content_widget=plot_widget)
qtbot.addWidget(frame)
qtbot.waitExposed(frame)
yield frame
cleanup_pyqtgraph(plot_widget)
def test_basic_rounded_frame_initialization(basic_rounded_frame):
assert basic_rounded_frame.radius == 10
assert basic_rounded_frame.content_widget is None
assert basic_rounded_frame.background_color is None
def test_set_radius(basic_rounded_frame):
basic_rounded_frame.radius = 20
assert basic_rounded_frame.radius == 20
def test_apply_plot_widget_style(plot_rounded_frame):
# Verify that a PlotWidget can have its style applied
plot_rounded_frame.apply_plot_widget_style(border="1px solid red")
# Ensure style application did not break anything
assert plot_rounded_frame.content_widget is not None
assert isinstance(plot_rounded_frame.content_widget, pg.PlotWidget)