diff --git a/bec_widgets/applications/launch_window.py b/bec_widgets/applications/launch_window.py index a8e47c16..8bae099c 100644 --- a/bec_widgets/applications/launch_window.py +++ b/bec_widgets/applications/launch_window.py @@ -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()) diff --git a/bec_widgets/utils/hover_gradient.py b/bec_widgets/utils/hover_gradient.py deleted file mode 100644 index 286cde14..00000000 --- a/bec_widgets/utils/hover_gradient.py +++ /dev/null @@ -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) diff --git a/bec_widgets/utils/round_frame.py b/bec_widgets/utils/round_frame.py deleted file mode 100644 index f8399d1b..00000000 --- a/bec_widgets/utils/round_frame.py +++ /dev/null @@ -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() diff --git a/bec_widgets/widgets/plots/image/image_roi_plot.py b/bec_widgets/widgets/plots/image/image_roi_plot.py index 7d32d6cf..e238be64 100644 --- a/bec_widgets/widgets/plots/image/image_roi_plot.py +++ b/bec_widgets/widgets/plots/image/image_roi_plot.py @@ -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.""" diff --git a/bec_widgets/widgets/plots/plot_base.py b/bec_widgets/widgets/plots/plot_base.py index 13591634..1b20b284 100644 --- a/bec_widgets/widgets/plots/plot_base.py +++ b/bec_widgets/widgets/plots/plot_base.py @@ -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)) diff --git a/bec_widgets/widgets/plots/waveform/waveform.py b/bec_widgets/widgets/plots/waveform/waveform.py index 7c156acc..e847ab2f 100644 --- a/bec_widgets/widgets/plots/waveform/waveform.py +++ b/bec_widgets/widgets/plots/waveform/waveform.py @@ -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: diff --git a/tests/unit_tests/test_round_frame.py b/tests/unit_tests/test_round_frame.py deleted file mode 100644 index 18c7152e..00000000 --- a/tests/unit_tests/test_round_frame.py +++ /dev/null @@ -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)