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:
@@ -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 per‑instance 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())
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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."""
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user