Compare commits

..

3 Commits

8 changed files with 241 additions and 73 deletions
+3 -25
View File
@@ -5,31 +5,28 @@ 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.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
@@ -46,7 +43,7 @@ logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class LaunchTile(QFrame):
class LaunchTile(RoundedFrame):
DEFAULT_SIZE = (250, 300)
open_signal = Signal()
@@ -59,14 +56,8 @@ class LaunchTile(QFrame):
description: str | None = None,
show_selector: bool = False,
tile_size: tuple[int, int] | None = None,
gradient: list[str] | None = None,
):
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)
super().__init__(parent=parent, orientation="vertical")
# Provide a perinstance TILE_SIZE so the class can compute layout
if tile_size is None:
@@ -162,12 +153,6 @@ class LaunchTile(QFrame):
"""
)
self.layout.addWidget(self.action_button, alignment=Qt.AlignCenter)
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):
"""
@@ -230,7 +215,6 @@ class LaunchWindow(BECMainWindow):
description="Highly flexible and customizable dock area application with modular widgets.",
action_button=lambda: self.launch("dock_area"),
show_selector=False,
gradient=["#B73665", "#232770"],
)
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
@@ -245,7 +229,6 @@ class LaunchWindow(BECMainWindow):
action_button=self._open_auto_update,
show_selector=True,
selector_items=list(self.available_auto_updates.keys()) + ["Default"],
gradient=["#EE0678", "#FF6A00"],
)
self.register_tile(
@@ -256,7 +239,6 @@ class LaunchWindow(BECMainWindow):
description="GUI application with custom UI file.",
action_button=self._open_custom_ui_file,
show_selector=False,
gradient=["#155799", "#179655"],
)
# plugin widgets
@@ -275,7 +257,6 @@ class LaunchWindow(BECMainWindow):
action_button=self._open_widget,
show_selector=True,
selector_items=list(self.available_widgets.keys()),
gradient=["#000046", "#1CB5E0"],
)
self._update_theme()
@@ -294,7 +275,6 @@ class LaunchWindow(BECMainWindow):
action_button: Callable | None = None,
show_selector: bool = False,
selector_items: list[str] | None = None,
gradient: list[str] | None = None,
):
"""
Register a tile in the launcher window.
@@ -317,7 +297,6 @@ class LaunchWindow(BECMainWindow):
description=description,
show_selector=show_selector,
tile_size=self.TILE_SIZE,
gradient=gradient,
)
tile.setFixedWidth(self.TILE_SIZE[0])
tile.setMinimumHeight(self.TILE_SIZE[1])
@@ -606,7 +585,6 @@ if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
apply_theme("dark")
launcher = LaunchWindow()
launcher.show()
sys.exit(app.exec())
@@ -1,3 +1,5 @@
from __future__ import annotations
import re
import markdown
@@ -13,6 +15,7 @@ from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget
from bec_widgets.widgets.containers.qt_ads import CDockWidget
from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
@@ -124,6 +127,7 @@ class DeveloperWidget(DockAreaWidget):
# Connect editor signals
self.explorer.file_open_requested.connect(self._open_new_file)
self.monaco.macro_file_updated.connect(self.explorer.refresh_macro_file)
self.monaco.focused_editor.connect(self._on_focused_editor_changed)
self.toolbar.show_bundles(["save", "execution", "settings"])
@@ -280,14 +284,17 @@ class DeveloperWidget(DockAreaWidget):
@SafeSlot()
def on_save(self):
"""Save the currently focused file in the Monaco editor."""
self.monaco.save_file()
@SafeSlot()
def on_save_as(self):
"""Save the currently focused file in the Monaco editor with a 'Save As' dialog."""
self.monaco.save_file(force_save_as=True)
@SafeSlot()
def on_vim_triggered(self):
"""Toggle Vim mode in the Monaco editor."""
self.monaco.set_vim_mode(self.toolbar.components.get_action("vim").action.isChecked())
@SafeSlot(bool)
@@ -310,16 +317,26 @@ class DeveloperWidget(DockAreaWidget):
@SafeSlot()
def on_stop(self):
"""Stop the execution of the currently running script"""
if not self.current_script_id:
return
self.console.send_ctrl_c()
@property
def current_script_id(self):
"""Get the ID of the currently running script."""
return self._current_script_id
@current_script_id.setter
def current_script_id(self, value: str | None):
"""
Set the ID of the currently running script.
Args:
value (str | None): The script ID to set.
Raises:
ValueError: If the provided value is not a string or None.
"""
if value is not None and not isinstance(value, str):
raise ValueError("Script ID must be a string.")
old_script_id = self._current_script_id
@@ -336,6 +353,28 @@ class DeveloperWidget(DockAreaWidget):
self.on_script_execution_info, MessageEndpoints.script_execution_info(new_script_id)
)
@SafeSlot(CDockWidget)
def _on_focused_editor_changed(self, tab_widget: CDockWidget):
"""
Disable the run button if the focused editor is a macro file.
Args:
tab_widget: The currently focused tab widget in the Monaco editor.
"""
if not isinstance(tab_widget, CDockWidget):
return
widget = tab_widget.widget()
if not isinstance(widget, MonacoWidget):
return
file_scope = widget.metadata.get("scope", "")
run_action = self.toolbar.components.get_action("run")
stop_action = self.toolbar.components.get_action("stop")
if "macro" in file_scope:
run_action.action.setEnabled(False)
stop_action.action.setEnabled(False)
else:
run_action.action.setEnabled(True)
stop_action.action.setEnabled(True)
@SafeSlot(dict, dict)
def on_script_execution_info(self, content: dict, metadata: dict):
"""
@@ -359,6 +398,7 @@ class DeveloperWidget(DockAreaWidget):
widget.set_highlighted_lines(line_number, line_number)
def cleanup(self):
"""Clean up resources used by the developer widget."""
self.delete_all()
return super().cleanup()
+126
View File
@@ -0,0 +1,126 @@
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()
@@ -142,9 +142,12 @@ class MonacoDock(DockAreaWidget):
# Temporarily disable read-only mode if the editor is read-only
# so we can clear the content for reuse
monaco_widget.set_readonly(False)
monaco_widget.set_text("")
monaco_widget.set_text("", reset=True)
dock.setWindowTitle("Untitled")
dock.setTabToolTip("Untitled")
monaco_widget.metadata["scope"] = ""
icon = self._resolve_dock_icon(monaco_widget, dock_icon=None, apply_widget_icon=True)
dock.setIcon(icon)
return
# Otherwise, proceed to close and delete the dock
@@ -249,10 +252,15 @@ class MonacoDock(DockAreaWidget):
self.last_focused_editor = dock
return dock
def open_file(self, file_name: str, scope: str | None = None) -> None:
def open_file(self, file_name: str, scope: str = "") -> None:
"""
Open a file in the specified area. If the file is already open, activate it.
Args:
file_name (str): The path to the file to open.
scope (str): The scope to set for the editor metadata.
"""
open_files = self._get_open_files()
if file_name in open_files:
dock = self._get_editor_dock(file_name)
@@ -281,8 +289,7 @@ class MonacoDock(DockAreaWidget):
editor_dock.setWindowTitle(file)
editor_dock.setTabToolTip(file_name)
editor_widget.open_file(file_name)
if scope is not None:
editor_widget.metadata["scope"] = scope
editor_widget.metadata["scope"] = scope
self.last_focused_editor = editor_dock
return
@@ -290,8 +297,7 @@ class MonacoDock(DockAreaWidget):
editor_dock = self.add_editor(title=file, tooltip=file_name)
widget = cast(MonacoWidget, editor_dock.widget())
widget.open_file(file_name)
if scope is not None:
widget.metadata["scope"] = scope
widget.metadata["scope"] = scope
editor_dock.setAsCurrentTab()
self.last_focused_editor = editor_dock
@@ -1,24 +1,16 @@
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(QFrame):
class ImageROIPlot(RoundedFrame):
"""
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)
@@ -35,15 +27,7 @@ class ImageROIPlot(QFrame):
self.curve_color = "k"
for curve in self.plot_item.curves:
curve.setPen(pg.mkPen(self.curve_color, width=3))
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("")
super().apply_theme(theme)
def cleanup_pyqtgraph(self):
"""Cleanup pyqtgraph items."""
+4 -21
View File
@@ -6,13 +6,14 @@ 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 QFrame, QHBoxLayout, QLabel, QMainWindow, QVBoxLayout, QWidget
from qtpy.QtWidgets import 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
@@ -143,43 +144,25 @@ class PlotBase(BECWidget, QWidget):
self._update_theme(None)
def apply_theme(self, theme: str):
self.apply_plot_widget_style()
super().apply_theme(theme)
self.round_plot_widget.apply_theme(theme)
def _init_ui(self):
self.layout.addWidget(self.layout_manager)
self.round_plot_widget = QFrame(parent=self)
self.round_plot_widget.setAttribute(Qt.WA_StyledBackground, True)
self.round_plot_widget = RoundedFrame(parent=self, content_widget=self.plot_widget)
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.apply_plot_widget_style() # To keep the correct theme
self.round_plot_widget.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.apply_plot_widget_style()
self.round_plot_widget.apply_plot_widget_style()
@SafeProperty(str)
def color_palette(self) -> str:
+51
View File
@@ -0,0 +1,51 @@
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)