Compare commits

..

3 Commits

74 changed files with 1285 additions and 6639 deletions
-21
View File
@@ -45,18 +45,6 @@ jobs:
cd ./bec
pip install pytest pytest-random-order
pytest -v --maxfail=2 --junitxml=report.xml --random-order ./bec_server/tests ./bec_ipython_client/tests/client_tests ./bec_lib/tests
- name: Upload BEC unit test artifacts if job fails
if: failure()
uses: actions/upload-artifact@v4
with:
name: bec-unit-test-artifacts
path: |
./bec/report.xml
./bec/logs/*.log
if-no-files-found: ignore
retention-days: 7
bec-e2e-test:
name: BEC End2End Tests
runs-on: ubuntu-latest
@@ -74,12 +62,3 @@ jobs:
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
PYTHON_VERSION: '3.11'
- name: Upload BEC e2e logs if job fails
if: failure()
uses: actions/upload-artifact@v4
with:
name: bec-e2e-test-logs
path: ./_e2e_test_checkout_/bec/logs/*.log
if-no-files-found: ignore
retention-days: 7
-224
View File
@@ -1,230 +1,6 @@
# CHANGELOG
## v3.15.0 (2026-06-12)
### Bug Fixes
- **beamline-states**: Better pydantic model handling
([`64cbf93`](https://github.com/bec-project/bec_widgets/commit/64cbf93d64895cc8af9522506512c4e8f5de939d))
- **bec_widget**: Removal of non existing TYPE check for old dock area
([`08650e8`](https://github.com/bec-project/bec_widgets/commit/08650e86a3d3aa1098514bad3d8119401ace1d3f))
- **device-input**: Align validity styling
([`6db198e`](https://github.com/bec-project/bec_widgets/commit/6db198e68422967c1c6d8ffb749b993e0e4975e1))
- **notification-banner**: Eventfilter guard for QStandartItem
([`d07d03c`](https://github.com/bec-project/bec_widgets/commit/d07d03c1be7b405e52126cfc507fdcd79093cb45))
- **notification-center**: Sync light theme styling
([`f78bc26`](https://github.com/bec-project/bec_widgets/commit/f78bc26a26bfebfc7df83a9c6cd7dd5c95a35d05))
- **pydantic**: Adoption to new ScanArgument refactor from bec
([`3dfed23`](https://github.com/bec-project/bec_widgets/commit/3dfed232efb8baff14ebe62b6b14e62e0a4acd36))
- **widget_it**: Device/signal combobox handler
([`434f9f5`](https://github.com/bec-project/bec_widgets/commit/434f9f561fca199aadc798db682a7e863a3246b3))
### Build System
- **bec**: Bump bec_lib and bec_ipython_client to v3.134
([`9550866`](https://github.com/bec-project/bec_widgets/commit/9550866b677bb304ab0ea490827d0d0c09064722))
### Features
- **beamline-states**: Add state manager widget
([`2546cc4`](https://github.com/bec-project/bec_widgets/commit/2546cc484d1c483fd45f1b80847a7e0200faba43))
- **beamline-states**: Collapse all functionality with cleanup of not used settings widgets if state
is not dirty
([`768c138`](https://github.com/bec-project/bec_widgets/commit/768c138576ad924dfad334888583131cc452e4f0))
- **dock-area**: Expose beamline state manager
([`6aa1f7e`](https://github.com/bec-project/bec_widgets/commit/6aa1f7e74ae4497d449b03e600e5c3fbbfc34be0))
- **forms**: Add pydantic widget form
([`b20897f`](https://github.com/bec-project/bec_widgets/commit/b20897f4bf2c05653e57bd79c5e292883d1f8ee8))
- **forms**: Unified pydantic and scan control adapter for pydantic models
([`563603b`](https://github.com/bec-project/bec_widgets/commit/563603b80e52ec6746a57fa68b1f7b2dbc101439))
- **widget_io**: Register handler
([`7e6dca4`](https://github.com/bec-project/bec_widgets/commit/7e6dca49120fba480a8756a0cb951b88a2df3584))
### Refactoring
- **beamline-states**: Beamlinestatemanager widget moved to separate module
([`4bb7e81`](https://github.com/bec-project/bec_widgets/commit/4bb7e811dd514e69e9a507505b976c3b8de1d035))
- **colors**: Consolidate theme helpers
([`aca2c4a`](https://github.com/bec-project/bec_widgets/commit/aca2c4a7a50e85b0d0f61251f71bc097a258599f))
- **main-window**: Remove status-tip override
([`f6f590c`](https://github.com/bec-project/bec_widgets/commit/f6f590cabdfd66f4bb4f8095414db5d120b0f9a1))
- **notification_banner**: Remove defensive patterns
([`64ed28b`](https://github.com/bec-project/bec_widgets/commit/64ed28ba4f554958305cc9cf37da1a124ba2a99b))
## v3.14.0 (2026-06-11)
### Bug Fixes
- **bec_progress_bar**: Replace the custom paint event progressbar with native QProgressBar
([`007f930`](https://github.com/bec-project/bec_widgets/commit/007f9306a62f60cf66a268f608984b6a954b8653))
- **progress**: Scan progress reset on_scan_status in unified backend
([`3d93cf2`](https://github.com/bec-project/bec_widgets/commit/3d93cf2f01778a9825a94adcba44a26fbeaf4be4))
- **ring**: Progresssignal fetch logic back
([`e8bd803`](https://github.com/bec-project/bec_widgets/commit/e8bd80377e0bee40142c5cd0d4a9c35d35f2d950))
- **scan_control**: Remove parent from layout to prevent `QLayout: Attempting to add QLayout "" to
ScanGroupBox "", which already has a layout`
([`e8e67f6`](https://github.com/bec-project/bec_widgets/commit/e8e67f68a2912c69a7df3d82daaa67fab3ae1139))
### Continuous Integration
- **child_repos**: Artifact logs upload if child pipelines fail
([`acfc1b4`](https://github.com/bec-project/bec_widgets/commit/acfc1b4b883b2a3cf0596c881489cb2c953dd219))
### Features
- **progress**: Progress is tracked from bec; unified progress backend
([`51f7652`](https://github.com/bec-project/bec_widgets/commit/51f7652b1fe59db6bf94a8183ae0e3a715601aa6))
### Refactoring
- **bec_progress**: Simplification of chunk radius calculation
([`e547ec7`](https://github.com/bec-project/bec_widgets/commit/e547ec71ae1f45db72d9b8cde0b5fe564466333c))
### Testing
- **e2e**: Increase rpc test_available_widgets timout back to 100
([`af125e2`](https://github.com/bec-project/bec_widgets/commit/af125e2222ff11a73f28dadb3a5d93e409ad010e))
## v3.13.5 (2026-06-02)
### Bug Fixes
- Change prints into proper logs
([`c8275fc`](https://github.com/bec-project/bec_widgets/commit/c8275fcfd5c920393df3aa201c32a632ac8086a5))
- **abort_button**: From __future__ import annotations
([`3796984`](https://github.com/bec-project/bec_widgets/commit/37969841822c8c38c23a1d8fca8e38aec684957b))
- **client_utils**: Increase default rpc timeout to 60s
([`07515d2`](https://github.com/bec-project/bec_widgets/commit/07515d24be6e930b1b40170fc710255914cb7454))
- **client_utils**: Stop output reader thread on shutdown
([`4572760`](https://github.com/bec-project/bec_widgets/commit/4572760b56ca2ab6435db3a6a4ba0d270e9008d1))
- **companion_app**: Disable logging of bec_lib.scan_items on widget side
([`bd66afb`](https://github.com/bec-project/bec_widgets/commit/bd66afb98dcb76ca87b0db1334df3c1af0a9dbad))
- **forms**: Gridlayout applied to widget which already has layout
([`1427c70`](https://github.com/bec-project/bec_widgets/commit/1427c70cfb6f84bbced7f72ec5cfa55ac0b9b742))
- **launch_window**: Exclude launcher check for non-parented widgets for BECMainWindow
([`ed68eb5`](https://github.com/bec-project/bec_widgets/commit/ed68eb5ac6b20cfc7ca2c0b91864dc54fb579499))
- **launcher**: Avoid orphan widgets detection and logging
([`9f94ca7`](https://github.com/bec-project/bec_widgets/commit/9f94ca7748d73a30622ecbaef384f4bc73a3d2fb))
- **logging**: Removed args/kwargs from logging messages
([`2fb7fb2`](https://github.com/bec-project/bec_widgets/commit/2fb7fb2ff487863c3bc931498496da74b25e52d8))
- **rpc**: Additional logs
([`e41e609`](https://github.com/bec-project/bec_widgets/commit/e41e60956b54890b70b3390b981196c9477abd93))
- **rpc**: Client/server rpc handshake for shutdown
([`8a180ea`](https://github.com/bec-project/bec_widgets/commit/8a180eaa7be5c1603d893cf3b50585f88f9b0c83))
- **rpc**: Log dispatcher receipt before qt callback
([`878745b`](https://github.com/bec-project/bec_widgets/commit/878745b99ac1e22c0fbddecc294e599469a2adfe))
- **rpc**: More robust shutdown section with PID logging
([`e42a982`](https://github.com/bec-project/bec_widgets/commit/e42a9824ccd54b71a3141aaf2aa4e02af6a13782))
- **rpc_server**: Log warning if rpc call is repeated
([`859563a`](https://github.com/bec-project/bec_widgets/commit/859563abb3e94ff55886e72db3177522900a89b8))
### Refactoring
- **client_utils**: Simplify PID fetching
([`154ae60`](https://github.com/bec-project/bec_widgets/commit/154ae6026a6471b7c1db42f7c2ff3dc7be4b4afb))
- **rpc**: Share logging helpers
([`8e1e282`](https://github.com/bec-project/bec_widgets/commit/8e1e282fac22ab6f726049758306c7ca17af70eb))
## v3.13.4 (2026-05-29)
### Bug Fixes
- **positioner_box**: Fix STOP button
([`9a58dba`](https://github.com/bec-project/bec_widgets/commit/9a58dba414d9eec32fd7de7fc64c97c38f020b84))
## v3.13.3 (2026-05-22)
### Bug Fixes
- **tests**: Rename description attribute to _description in FakeDevice
([`668b1bd`](https://github.com/bec-project/bec_widgets/commit/668b1bd9cd158fc12cff2c340d7317f30a212121))
## v3.13.2 (2026-05-22)
### Bug Fixes
- **tests**: Rename description attribute to _description in FakePositioner
([`c346bd0`](https://github.com/bec-project/bec_widgets/commit/c346bd0f18ce873ff5ca6c59150c9581c9edca8d))
## v3.13.1 (2026-05-21)
### Bug Fixes
- Use .show instead of .start
([`b4beb27`](https://github.com/bec-project/bec_widgets/commit/b4beb274da745da618f9b37ec241cd0109c088f1))
- **gui**: Replace window.show() with window.raise_window() and add hide() method
([`f7a48b5`](https://github.com/bec-project/bec_widgets/commit/f7a48b5f6a51d391dca26ca42d03bad4f278ff22))
## v3.13.0 (2026-05-21)
### Features
- **rpc-base**: Set default RPC timeout and allow customization
([`f03a5d9`](https://github.com/bec-project/bec_widgets/commit/f03a5d9e853bd62b8ec1bad1c1e112fe01befe70))
## v3.12.2 (2026-05-21)
### Bug Fixes
- **toggle**: Disable styling implemented
([`9eb0541`](https://github.com/bec-project/bec_widgets/commit/9eb05416ab68dcb88732dca8974c665030d34e0b))
## v3.12.1 (2026-05-21)
### Bug Fixes
- **device_input**: Correct cleanup unsubscribe
([`56427a7`](https://github.com/bec-project/bec_widgets/commit/56427a7f0c3a89fe847d415c8b45212e663434c4))
- **device_input**: Ensure callback is removed after cleanup
([`d99db7d`](https://github.com/bec-project/bec_widgets/commit/d99db7d04208945b86a39d65022b211ba093caed))
- **signal_combobox**: Signature matched for update_signals_from_filters
([`a976837`](https://github.com/bec-project/bec_widgets/commit/a976837cff612349f2a3f17900903c203bc3d250))
## v3.12.0 (2026-05-20)
### Bug Fixes
+20 -65
View File
@@ -5,7 +5,6 @@ import json
import os
import signal
import sys
import traceback
from contextlib import redirect_stderr, redirect_stdout
import darkdetect
@@ -64,7 +63,6 @@ class GUIServer:
self.app: QApplication | None = None
self.launcher_window: LaunchWindow | None = None
self.dispatcher: BECDispatcher | None = None
self._shutdown_started = False
def start(self):
"""
@@ -76,7 +74,6 @@ class GUIServer:
bec_logger._stderr_log_level = bec_logger.LOGLEVEL.ERROR
bec_logger._update_sinks()
bec_logger.disabled_modules = ["bec_lib.scan_items"]
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)): # type: ignore
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)): # type: ignore
self._run()
@@ -125,8 +122,17 @@ class GUIServer:
self.app.aboutToQuit.connect(self.shutdown)
self.app.setQuitOnLastWindowClosed(True)
signal.signal(signal.SIGINT, self.request_shutdown)
signal.signal(signal.SIGTERM, self.request_shutdown)
def sigint_handler(*args):
# display message, for people to let it terminate gracefully
print("Caught SIGINT, exiting")
# Widgets should be all closed.
with RPCRegister.delayed_broadcast():
for widget in QApplication.instance().topLevelWidgets(): # type: ignore
widget.close()
self.shutdown()
signal.signal(signal.SIGINT, sigint_handler)
signal.signal(signal.SIGTERM, sigint_handler)
sys.exit(self.app.exec())
@@ -143,67 +149,16 @@ class GUIServer:
)
self.app.setWindowIcon(icon)
def request_shutdown(self, signum=None, _frame=None):
"""
Request Qt application shutdown from an RPC call or OS signal.
Cleanup itself is handled by ``shutdown()``, which is connected to
``QApplication.aboutToQuit``. Calling it directly here would run BEC/RPC
teardown before Qt has processed the widget close events.
"""
signal_name = signal.Signals(signum).name if signum is not None else "shutdown"
pid = os.getpid()
if self.app is None:
logger.info(f"Caught {signal_name}, shutting down GUI server pid={pid} without app")
self.shutdown()
return
widgets = [
f"{widget.__class__.__name__}(objectName={widget.objectName()!r})"
for widget in self.app.topLevelWidgets()
]
logger.info(
f"Caught {signal_name}, requesting GUI server shutdown pid={pid} "
f"top_level_widgets={widgets}"
)
with RPCRegister.delayed_broadcast():
for widget in self.app.topLevelWidgets():
widget.close()
self.app.quit()
@staticmethod
def _run_shutdown_step(step: str, callback):
try:
callback()
except Exception as exc:
logger.error(
f"GUIServer shutdown step failed pid={os.getpid()} step={step}: {exc}\n"
f"{traceback.format_exc()}"
)
def shutdown(self):
if self._shutdown_started:
return
self._shutdown_started = True
logger.info(f"Shutdown GUIServer pid={os.getpid()} {repr(self)}")
def close_launcher_window():
if self.launcher_window and shiboken6.isValid(self.launcher_window):
self.launcher_window.close()
self.launcher_window.deleteLater()
def stop_pylsp_server():
if pylsp_server.is_running():
pylsp_server.stop()
def stop_dispatcher():
if self.dispatcher:
self.dispatcher.stop_cli_server()
self.dispatcher.disconnect_all()
self._run_shutdown_step("close_launcher_window", close_launcher_window)
self._run_shutdown_step("stop_pylsp_server", stop_pylsp_server)
self._run_shutdown_step("stop_dispatcher", stop_dispatcher)
logger.info("Shutdown GUIServer", repr(self))
if self.launcher_window and shiboken6.isValid(self.launcher_window):
self.launcher_window.close()
self.launcher_window.deleteLater()
if pylsp_server.is_running():
pylsp_server.stop()
if self.dispatcher:
self.dispatcher.stop_cli_server()
self.dispatcher.disconnect_all()
def main():
+26 -57
View File
@@ -207,7 +207,6 @@ class LaunchWindow(BECMainWindow):
self.app = QApplication.instance()
self.tiles: dict[str, LaunchTile] = {}
self._logged_unparented_connections: set[str] = set()
# Track the smallest mainlabel font size chosen so far
self._min_main_label_pt: int | None = None
@@ -656,83 +655,53 @@ class LaunchWindow(BECMainWindow):
super().showEvent(event)
self.setFixedSize(self.size())
def _has_external_window(self, connections: dict) -> bool:
def _launcher_is_last_widget(self, connections: dict) -> bool:
"""
Check if any registered non-launcher connection owns a top-level Qt window.
Check if the launcher is the last widget in the application.
"""
# get all parents of connections
for connection in connections.values():
if self._connection_belongs_to_launcher(connection):
continue
if isinstance(connection, QWidget) and connection.isWindow():
return True
return False
def _log_unparented_connections(self, connections: dict) -> None:
"""
Log non-launcher RPC connections that remain without an active top-level window.
"""
for connection in connections.values():
if self._connection_belongs_to_launcher(connection):
continue
if isinstance(connection, QWidget) and connection.isWindow():
continue
connection_description = (
f"type={type(connection).__name__} objectName={connection.objectName()!r} "
f"gui_id={connection.gui_id!r}"
)
if connection_description in self._logged_unparented_connections:
continue
self._logged_unparented_connections.add(connection_description)
logger.warning(
"Registered non-launcher RPC connection has no active top-level window: "
f"{connection_description}"
)
def _connection_belongs_to_launcher(self, connection: QObject) -> bool:
"""
Check whether a registered connection is the launcher itself or part of its Qt hierarchy.
"""
if connection is self or connection.gui_id == self.gui_id:
return True
parent = connection.parent()
while parent is not None:
if parent is self:
return True
parent = parent.parent()
return False
try:
parent = connection.parent()
if parent is None and connection.objectName() != self.objectName():
logger.info(
f"Found non-launcher connection without parent: {connection.objectName()}"
)
return False
except Exception as e:
logger.error(f"Error getting parent of connection: {e}")
return False
return True
def _turn_off_the_lights(self, connections: dict):
"""
If there is only one connection remaining, it is the launcher, so we show it.
Once the launcher is closed as the last window, we quit the application.
"""
if self._has_external_window(connections):
self.hide()
if self._launcher_is_last_widget(connections):
self.show()
self.activateWindow()
self.raise_()
if self.app:
self.app.setQuitOnLastWindowClosed(False) # type: ignore
self.app.setQuitOnLastWindowClosed(True) # type: ignore
return
self._log_unparented_connections(connections)
self.show()
self.activateWindow()
self.raise_()
self.hide()
if self.app:
self.app.setQuitOnLastWindowClosed(True) # type: ignore
self.app.setQuitOnLastWindowClosed(False) # type: ignore
def closeEvent(self, event):
"""
Close the launcher window.
"""
connections = self.register.list_all_connections()
if self._has_external_window(connections):
event.ignore()
self.hide()
if self._launcher_is_last_widget(connections):
event.accept()
return
event.accept()
event.ignore()
self.hide()
if __name__ == "__main__": # pragma: no cover
+1 -54
View File
@@ -32,7 +32,6 @@ _Widgets = {
"BECQueue": "BECQueue",
"BECShell": "BECShell",
"BECStatusBox": "BECStatusBox",
"BeamlineStateManager": "BeamlineStateManager",
"BecConsole": "BecConsole",
"DapComboBox": "DapComboBox",
"DeviceBrowser": "DeviceBrowser",
@@ -428,7 +427,7 @@ class BECMainWindow(RPCBase):
class BECProgressBar(RPCBase):
"""A BEC progress bar backed by Qt's native QProgressBar."""
"""A custom progress bar with smooth transitions. The displayed text can be customized using a template."""
_IMPORT_MODULE = "bec_widgets.widgets.progress.bec_progressbar.bec_progressbar"
@@ -718,58 +717,6 @@ class BaseROI(RPCBase):
"""
class BeamlineStateManager(RPCBase):
"""Widget displaying and managing all BEC beamline states."""
_IMPORT_MODULE = "bec_widgets.widgets.services.beamline_states.beamline_state_manager"
@rpc_call
def clear_filters(self) -> "None":
"""
None
"""
@rpc_call
def collapse_all(self) -> "None":
"""
Collapse the settings panel of all displayed state pills.
"""
@rpc_call
def state_summary(self) -> "dict[str, dict[str, str]]":
"""
Return all beamline states (including filtered ones) with their current status and label.
Returns:
dict: Mapping of state name to a dictionary with ``status`` and ``label`` keys.
"""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
class BecConsole(RPCBase):
"""A console widget with access to a shared registry of terminals, such that instances can be moved around."""
+11 -159
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import json
import os
import select
import signal
import subprocess
import threading
import time
@@ -34,12 +33,6 @@ else:
logger = bec_logger.logger
IGNORE_WIDGETS = ["LaunchWindow"]
PROCESS_TERMINATION_TIMEOUT = 10
PROCESS_OUTPUT_THREAD_JOIN_TIMEOUT = 2
PROCESS_OUTPUT_SELECT_TIMEOUT = 0.2
GRACEFUL_SERVER_SHUTDOWN_RPC_TIMEOUT = 3
GRACEFUL_SERVER_SHUTDOWN_TIMEOUT = 5
OUTPUT_READER_STOP_EVENT_ATTR = "_bec_output_reader_stop_event"
RegistryState: TypeAlias = dict[
Literal["gui_id", "name", "widget_class", "config", "__rpc__", "container_proxy"],
@@ -60,16 +53,14 @@ def _filter_output(output: str) -> str:
return output
def _get_output(process, logger, stop_event: threading.Event | None = None) -> None:
def _get_output(process, logger) -> None:
log_func = {process.stdout: logger.debug, process.stderr: logger.info}
stream_buffer = {process.stdout: [], process.stderr: []}
try:
os.set_blocking(process.stdout.fileno(), False)
os.set_blocking(process.stderr.fileno(), False)
while process.poll() is None and not (stop_event and stop_event.is_set()):
readylist, _, _ = select.select(
[process.stdout, process.stderr], [], [], PROCESS_OUTPUT_SELECT_TIMEOUT
)
while process.poll() is None:
readylist, _, _ = select.select([process.stdout, process.stderr], [], [], 1)
for stream in (process.stdout, process.stderr):
buf = stream_buffer[stream]
if stream in readylist:
@@ -84,95 +75,6 @@ def _get_output(process, logger, stop_event: threading.Event | None = None) -> N
logger.error(f"Error reading process output: {str(e)}")
def _process_group_snapshot(process) -> str:
try:
pgid = os.getpgid(process.pid)
except ProcessLookupError:
return "Process group snapshot unavailable: process already exited"
try:
result = subprocess.run(
["ps", "-o", "pid,ppid,pgid,stat,command", "-g", str(pgid)],
check=False,
capture_output=True,
text=True,
timeout=2,
)
except Exception as exc:
return f"Process group snapshot unavailable: {exc}"
output = result.stdout.strip()
if not output:
return f"Process group snapshot empty for pgid={pgid}"
return output
def _terminate_plot_process(process, logger, timeout: float = PROCESS_TERMINATION_TIMEOUT) -> None:
if process.poll() is not None:
return
process_info = f"pid={process.pid} command={process.args}"
try:
pgid = os.getpgid(process.pid)
process_info = f"pid={process.pid} pgid={pgid} command={process.args}"
logger.info(f"Terminating GUI process group {process_info}")
os.killpg(pgid, signal.SIGTERM)
except ProcessLookupError:
process.wait(timeout=timeout)
return
except Exception as exc:
logger.warning("Failed to terminate GUI process group; terminating process only.")
logger.info(f"GUI process termination failure details: {exc}. pid={process.pid}")
process.terminate()
try:
process.wait(timeout=timeout)
return
except subprocess.TimeoutExpired:
logger.warning(f"GUI process did not stop within {timeout}s; killing it.")
logger.info(
f"GUI process force-kill details: {process_info}\n"
f"{_process_group_snapshot(process)}"
)
try:
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
except ProcessLookupError as e:
logger.error(f"Failed to kill GUI process group: {e}")
process.wait(timeout=timeout)
return
process.wait(timeout=timeout)
def _wait_for_process_exit(process, timeout: float) -> bool:
try:
process.wait(timeout=timeout)
except subprocess.TimeoutExpired:
return False
return True
def _join_process_output_thread(process, thread: threading.Thread | None, logger) -> None:
if thread is None:
return
thread.join(timeout=PROCESS_OUTPUT_THREAD_JOIN_TIMEOUT)
if not thread.is_alive():
return
if stop_event := getattr(thread, OUTPUT_READER_STOP_EVENT_ATTR, None):
stop_event.set()
for stream in (process.stdout, process.stderr):
if stream is None:
continue
try:
stream.close()
except OSError as e:
logger.error(f"Failed to close stream {str(e)}")
thread.join(timeout=PROCESS_OUTPUT_THREAD_JOIN_TIMEOUT)
if thread.is_alive():
logger.warning("GUI process output reader thread did not stop after process shutdown.")
logger.info(f"GUI process output reader thread details: pid={process.pid}")
def _start_plot_process(
gui_id: str,
gui_class_id: str,
@@ -224,14 +126,8 @@ def _start_plot_process(
if logger is None:
process_output_processing_thread = None
else:
process_output_stop_event = threading.Event()
process_output_processing_thread = threading.Thread(
target=_get_output, args=(process, logger, process_output_stop_event)
)
setattr(
process_output_processing_thread,
OUTPUT_READER_STOP_EVENT_ATTR,
process_output_stop_event,
target=_get_output, args=(process, logger)
)
process_output_processing_thread.start()
return process, process_output_processing_thread
@@ -326,7 +222,6 @@ class BECGuiClient(RPCBase):
self._ipython_registry: dict[str, RPCReference] = {}
self.available_widgets = AvailableWidgetsNamespace()
register_serializer_extension()
self._rpc_timeout = 60
####################
#### Client API ####
@@ -337,16 +232,6 @@ class BECGuiClient(RPCBase):
"""The launcher object."""
return RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self, object_name="launcher")
def set_rpc_timeout(self, timeout: float):
"""Set the timeout for RPC calls to the GUI server.
Args:
timeout(float): The timeout in seconds.
"""
if not isinstance(timeout, (int, float)) or timeout < 0:
raise ValueError("Timeout must be a non-negative number.")
self._rpc_timeout = timeout
def _safe_register_stream(self, endpoint: EndpointInfo, cb: Callable, **kwargs):
"""Check if already registered for registration in idempotent functions."""
if not self._client.connector.any_stream_is_registered(endpoint, cb=cb):
@@ -473,7 +358,7 @@ class BECGuiClient(RPCBase):
)
if not self._check_if_server_is_alive():
self.show(wait=True)
self.start(wait=True)
if wait:
with wait_for_server(self):
return self._new_impl(
@@ -569,13 +454,11 @@ class BECGuiClient(RPCBase):
if self._process:
logger.success("Stopping GUI...")
if not self._request_server_shutdown():
_terminate_plot_process(self._process, logger)
_join_process_output_thread(
self._process, self._process_output_processing_thread, logger
)
self._process.terminate()
if self._process_output_processing_thread:
self._process_output_processing_thread.join()
self._process.wait()
self._process = None
self._process_output_processing_thread = None
# Unregister the registry state
self._client.connector.unregister(
@@ -594,37 +477,6 @@ class BECGuiClient(RPCBase):
#### Private methods ####
#########################
def _request_server_shutdown(self) -> bool:
if self._process is None or self._process.poll() is not None:
return True
process_details = f"pid={self._process.pid} command={self._process.args}"
logger.info(f"Requesting graceful GUI shutdown {process_details}")
try:
self.launcher._run_rpc( # pylint: disable=protected-access
"system.shutdown",
wait_for_rpc_response=True,
timeout=GRACEFUL_SERVER_SHUTDOWN_RPC_TIMEOUT,
)
except Exception as exc:
logger.warning(
"Could not confirm graceful GUI shutdown via RPC; "
"falling back to process termination."
)
logger.info(f"Graceful GUI shutdown RPC failure details: {exc}. {process_details}")
return False
if _wait_for_process_exit(self._process, GRACEFUL_SERVER_SHUTDOWN_TIMEOUT):
logger.info(f"GUI server exited after graceful shutdown {process_details}")
return True
logger.warning(
"GUI server did not exit after graceful shutdown request; "
"falling back to process termination."
)
logger.info(
f"Graceful GUI shutdown timeout details: {process_details}\n"
f"{_process_group_snapshot(self._process)}"
)
return False
def _check_if_server_is_alive(self):
"""Checks if the process is alive"""
if self._process is None:
@@ -698,7 +550,7 @@ class BECGuiClient(RPCBase):
if self.launcher and len(self._top_level) == 0:
self.launcher._run_rpc("show") # pylint: disable=protected-access
for window in self._top_level.values():
window.raise_window()
window.show()
def _show_all(self):
with wait_for_server(self):
@@ -717,7 +569,7 @@ class BECGuiClient(RPCBase):
if self.launcher and len(self._top_level) == 0:
self.launcher._run_rpc("raise") # pylint: disable=protected-access
for window in self._top_level.values():
window.raise_window()
window._run_rpc("raise") # type: ignore[attr-defined]
def _raise_all(self):
with wait_for_server(self):
-5
View File
@@ -19,10 +19,6 @@ designer_plugins = {
"BECShell": ("bec_widgets.widgets.editors.bec_console.bec_console", "BECShell"),
"BECSpinBox": ("bec_widgets.widgets.utility.spinbox.decimal_spinbox", "BECSpinBox"),
"BECStatusBox": ("bec_widgets.widgets.services.bec_status_box.bec_status_box", "BECStatusBox"),
"BeamlineStateManager": (
"bec_widgets.widgets.services.beamline_states.beamline_state_manager",
"BeamlineStateManager",
),
"BecConsole": ("bec_widgets.widgets.editors.bec_console.bec_console", "BecConsole"),
"ColorButton": ("bec_widgets.widgets.utility.visual.color_button.color_button", "ColorButton"),
"ColorButtonNative": (
@@ -122,7 +118,6 @@ widget_icons = {
"BECShell": "hub",
"BECSpinBox": "123",
"BECStatusBox": "widgets",
"BeamlineStateManager": "format_list_bulleted",
"BecConsole": "terminal",
"ColorButton": "colors",
"ColorButtonNative": "colors",
+3 -50
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import inspect
import threading
import time
import uuid
from functools import wraps
from typing import TYPE_CHECKING, Any, cast
@@ -10,7 +9,6 @@ from typing import TYPE_CHECKING, Any, cast
from bec_lib.client import BECClient
from bec_lib.device import DeviceBaseWithConfig
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
if TYPE_CHECKING: # pragma: no cover
@@ -26,9 +24,6 @@ else:
# pylint: disable=protected-access
_DEFAULT_RPC_TIMEOUT = object()
logger = bec_logger.logger
def _name_arg(arg):
if isinstance(arg, DeviceBaseWithConfig):
@@ -159,7 +154,6 @@ class RPCReference:
class RPCBase:
def __init__(
self,
gui_id: str | None = None,
@@ -213,16 +207,12 @@ class RPCBase:
# Use explicit call to ensure action name is 'raise' (not 'raise_')
return self._run_rpc("raise")
def hide(self):
"""Hide this widget (or its container)."""
return self._run_rpc("hide")
def _run_rpc(
self,
method,
*args,
wait_for_rpc_response: bool = True,
timeout: float | None | object = _DEFAULT_RPC_TIMEOUT,
wait_for_rpc_response=True,
timeout=5,
gui_id: str | None = None,
**kwargs,
) -> Any:
@@ -233,16 +223,13 @@ class RPCBase:
method: The method to call.
args: The arguments to pass to the method.
wait_for_rpc_response: Whether to wait for the RPC response.
timeout: The timeout for the RPC response. If omitted, the client's default RPC
timeout is used. If explicitly set to None, wait indefinitely.
timeout: The timeout for the RPC response.
gui_id: The GUI ID to use for the RPC call. If None, the default GUI ID is used.
kwargs: The keyword arguments to pass to the method.
Returns:
The result of the RPC call.
"""
if timeout is _DEFAULT_RPC_TIMEOUT:
timeout = self._root._rpc_timeout
if method in ["show", "hide", "raise"] and gui_id is None:
obj = self._root._server_registry.get(self._gui_id)
if obj is None:
@@ -264,39 +251,12 @@ class RPCBase:
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
)
target_gui_id = gui_id or self._gui_id
sent_at = time.time()
deadline = sent_at + timeout if timeout is not None else None
rpc_msg.metadata.update(
{
"method": method,
"receiver": receiver,
"target_gui_id": target_gui_id,
"object_name": self.object_name,
"wait_for_response": wait_for_rpc_response,
"timeout": timeout,
"sent_at": sent_at,
"deadline": deadline,
}
)
logger.info(
"Sending GUI RPC request "
f"request_id={request_id} method={method} receiver={receiver} "
f"target_gui_id={target_gui_id} object_name={self.object_name} "
f"wait_for_response={wait_for_rpc_response} timeout={timeout}"
)
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
if wait_for_rpc_response:
try:
finished = self._msg_wait_event.wait(timeout)
if not finished:
logger.error(
"GUI RPC response timeout "
f"request_id={request_id} method={method} receiver={receiver} "
f"target_gui_id={target_gui_id} object_name={self.object_name} "
f"timeout={timeout}"
)
raise RPCResponseTimeoutError(request_id, timeout)
finally:
self._msg_wait_event.clear()
@@ -308,12 +268,6 @@ class RPCBase:
# the _on_rpc_response method
assert isinstance(self._rpc_response, messages.RequestResponseMessage)
logger.info(
"Received GUI RPC response "
f"request_id={request_id} method={method} receiver={receiver} "
f"target_gui_id={target_gui_id} object_name={self.object_name} "
f"accepted={self._rpc_response.accepted}"
)
if not self._rpc_response.accepted:
raise ValueError(self._rpc_response.message["error"])
msg_result = self._rpc_response.message.get("result")
@@ -322,7 +276,6 @@ class RPCBase:
def _on_rpc_response(self, msg_obj: MessageObject) -> None:
msg = cast(messages.RequestResponseMessage, msg_obj.value)
logger.debug(f"GUI RPC response callback received: {msg}")
self._rpc_response = msg
self._msg_wait_event.set()
+4 -4
View File
@@ -15,7 +15,7 @@ class FakeDevice(BECDevice):
super().__init__(name=name)
self._enabled = enabled
self.signals = {self.name: {"value": 1.0}}
self._description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
self._readout_priority = readout_priority
self._config = {
"readoutPriority": "baseline",
@@ -74,7 +74,7 @@ class FakeDevice(BECDevice):
Returns:
dict: Description of the device
"""
return self._description
return self.description
class FakePositioner(BECPositioner):
@@ -96,7 +96,7 @@ class FakePositioner(BECPositioner):
self._limits = limits
self._readout_priority = readout_priority
self.signals = {self.name: {"value": 1.0}}
self._description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
self.description = {self.name: {"source": self.name, "dtype": "number", "shape": []}}
self._config = {
"readoutPriority": "baseline",
"deviceClass": "ophyd_devices.SimPositioner",
@@ -176,7 +176,7 @@ class FakePositioner(BECPositioner):
Returns:
dict: Description of the device
"""
return self._description
return self.description
@property
def precision(self):
+1 -38
View File
@@ -3,9 +3,8 @@ from __future__ import annotations
import collections
import random
import string
import time
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, DefaultDict, Hashable, Union
from typing import TYPE_CHECKING, DefaultDict, Hashable, Union
import louie
import redis
@@ -16,7 +15,6 @@ from bec_lib.service_config import ServiceConfig
from qtpy.QtCore import QObject
from qtpy.QtCore import Signal as pyqtSignal
from bec_widgets.utils.rpc_logging import elapsed_seconds, format_elapsed
from bec_widgets.utils.serialization import register_serializer_extension
logger = bec_logger.logger
@@ -27,39 +25,6 @@ if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.rpc_server import RPCServer
def _log_rpc_dispatcher_receive(msg_content: Any, metadata: Any) -> None:
if not isinstance(msg_content, dict) or not isinstance(metadata, dict):
return
request_id = metadata.get("request_id")
method = msg_content.get("action")
parameter = msg_content.get("parameter")
if request_id is None or method is None or not isinstance(parameter, dict):
return
dispatch_received_at = time.time()
sent_at = metadata.get("sent_at")
deadline = metadata.get("deadline")
timeout = metadata.get("timeout")
dispatch_latency = elapsed_seconds(sent_at, dispatch_received_at)
stale_on_dispatch = deadline is not None and dispatch_received_at > deadline
target_gui_id = parameter.get("gui_id") or metadata.get("target_gui_id")
logger.info(
"GUI RPC dispatcher received request before Qt callback emit "
f"request_id={request_id} method={method} receiver={metadata.get('receiver')} "
f"target_gui_id={target_gui_id} object_name={metadata.get('object_name')} "
f"timeout={timeout} dispatch_latency_s={format_elapsed(dispatch_latency)} "
f"stale_on_dispatch={stale_on_dispatch}"
)
if stale_on_dispatch:
logger.warning(
"GUI RPC dispatcher received request after client timeout deadline "
f"request_id={request_id} method={method} receiver={metadata.get('receiver')} "
f"target_gui_id={target_gui_id} object_name={metadata.get('object_name')} "
f"timeout={timeout} dispatch_latency_s={format_elapsed(dispatch_latency)}"
)
class QtThreadSafeCallback(QObject):
"""QtThreadSafeCallback is a wrapper around a callback function to make it thread-safe for Qt."""
@@ -123,12 +88,10 @@ class QtRedisConnector(RedisConnector):
# we can notice kwargs are lost when passed to Qt slot
metadata = msg.metadata
_log_rpc_dispatcher_receive(msg.content, metadata)
cb(msg.content, metadata)
else:
# from stream
msg = msg["data"]
_log_rpc_dispatcher_receive(msg.content, msg.metadata)
cb(msg.content, msg.metadata)
+24 -25
View File
@@ -20,6 +20,7 @@ from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.busy_loader import BusyLoaderOverlay
from bec_widgets.widgets.containers.dock import BECDock
logger = bec_logger.logger
@@ -330,34 +331,32 @@ class BECWidget(BECConnector):
# All widgets need to call super().cleanup() in their cleanup method
logger.info(f"Registry cleanup for widget {self.__class__.__name__}")
self.rpc_register.remove_rpc(self)
children = self.findChildren(BECWidget)
for child in children:
if not shiboken6.isValid(child):
# If the child is not valid, it means it has already been deleted
continue
child.close()
child.deleteLater()
children = self.findChildren(BECWidget)
for child in children:
if not shiboken6.isValid(child):
# If the child is not valid, it means it has already been deleted
continue
child.close()
child.deleteLater()
# Tear down busy overlay explicitly to stop spinner and remove filters
overlay = getattr(self, "_busy_overlay", None)
if overlay is not None and shiboken6.isValid(overlay):
try:
overlay.hide()
filt = getattr(overlay, "_filter", None)
if filt is not None and shiboken6.isValid(filt):
try:
self.removeEventFilter(filt)
except Exception as exc:
logger.warning(
f"Failed to remove event filter from busy overlay: {exc}"
)
# Tear down busy overlay explicitly to stop spinner and remove filters
overlay = getattr(self, "_busy_overlay", None)
if overlay is not None and shiboken6.isValid(overlay):
try:
overlay.hide()
filt = getattr(overlay, "_filter", None)
if filt is not None and shiboken6.isValid(filt):
try:
self.removeEventFilter(filt)
except Exception as exc:
logger.warning(f"Failed to remove event filter from busy overlay: {exc}")
# Cleanup the overlay widget. This will call cleanup on the custom widget if present.
# Cleanup the overlay widget. This will call cleanup on the custom widget if present.
overlay.cleanup()
overlay.deleteLater()
except Exception as exc:
logger.warning(f"Failed to delete busy overlay: {exc}")
overlay.cleanup()
overlay.deleteLater()
except Exception as exc:
logger.warning(f"Failed to delete busy overlay: {exc}")
def closeEvent(self, event):
"""Wrap the close even to ensure the rpc_register is cleaned up."""
+65 -34
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import re
from functools import lru_cache
from typing import Any, Literal
from typing import Literal
import numpy as np
import pyqtgraph as pg
@@ -21,7 +21,8 @@ logger = bec_logger.logger
def get_theme_name():
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
return "dark"
return QApplication.instance().theme.theme
else:
return QApplication.instance().theme.theme
def get_theme_palette():
@@ -57,25 +58,6 @@ def apply_theme(theme: Literal["dark", "light"]):
process_all_deferred_deletes(QApplication.instance())
def theme_color(theme: Any | None, key: str, fallback: QColor | str) -> QColor:
"""
Return a QColor from a BEC theme, or the fallback when no theme is set.
"""
fallback_color = fallback if isinstance(fallback, QColor) else QColor(str(fallback))
if theme is None:
return fallback_color
return theme.color(key, fallback_color.name())
def rgba(color: QColor | str, alpha: int) -> str:
"""
Return a QSS-compatible rgba string.
"""
qcolor = color if isinstance(color, QColor) else QColor(str(color))
return f"rgba({qcolor.red()}, {qcolor.green()}, {qcolor.blue()}, {alpha})"
class Colors:
@staticmethod
def list_available_colormaps() -> list[str]:
@@ -168,6 +150,25 @@ class Colors:
return ge.colorMap()
@staticmethod
def golden_ratio(num: int) -> list:
"""Calculate the golden ratio for a given number of angles.
Args:
num (int): Number of angles
Returns:
list: List of angles calculated using the golden ratio.
"""
phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2)
angles = []
for ii in range(num):
x = np.cos(ii * phi)
y = np.sin(ii * phi)
angle = np.arctan2(y, x)
angles.append(angle)
return angles
@staticmethod
def set_theme_offset(theme: Literal["light", "dark"] | None = None, offset=0.2) -> tuple:
"""
@@ -238,7 +239,20 @@ class Colors:
else:
positions = np.linspace(min_pos, max_pos, num)
return Colors._format_mapped_colors(cmap.map(positions, mode="float"), format)
# Sample colors from the colormap at the calculated positions
colors = cmap.map(positions, mode="float")
color_list = []
for color in colors:
if format.upper() == "HEX":
color_list.append(QColor.fromRgbF(*color).name())
elif format.upper() == "RGB":
color_list.append(tuple((np.array(color) * 255).astype(int)))
elif format.upper() == "QCOLOR":
color_list.append(QColor.fromRgbF(*color))
else:
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
return color_list
@staticmethod
def golden_angle_color(
@@ -274,19 +288,20 @@ class Colors:
positions = np.mod(np.arange(num) * golden_angle_conjugate, 1)
positions = min_pos + positions * (max_pos - min_pos)
return Colors._format_mapped_colors(cmap.map(positions, mode="float"), format)
# Sample colors from the colormap at the calculated positions
colors = cmap.map(positions, mode="float")
color_list = []
@staticmethod
def _format_mapped_colors(colors: np.ndarray, format: Literal["QColor", "HEX", "RGB"]) -> list:
color_format = format.upper()
if color_format not in {"QCOLOR", "HEX", "RGB"}:
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
if color_format == "QCOLOR":
return [QColor.fromRgbF(*color) for color in colors]
if color_format == "HEX":
return [QColor.fromRgbF(*color).name() for color in colors]
return [tuple((np.array(color) * 255).astype(int)) for color in colors]
for color in colors:
if format.upper() == "HEX":
color_list.append(QColor.fromRgbF(*color).name())
elif format.upper() == "RGB":
color_list.append(tuple((np.array(color) * 255).astype(int)))
elif format.upper() == "QCOLOR":
color_list.append(QColor.fromRgbF(*color))
else:
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
return color_list
@staticmethod
def hex_to_rgba(hex_color: str, alpha=255) -> tuple:
@@ -310,6 +325,22 @@ class Colors:
raise ValueError("HEX color must be 6 or 8 characters long.")
return (r, g, b, alpha)
@staticmethod
def rgba_to_hex(r: int, g: int, b: int, a: int = 255) -> str:
"""
Convert RGBA color to HEX.
Args:
r(int): Red value (0-255).
g(int): Green value (0-255).
b(int): Blue value (0-255).
a(int): Alpha value (0-255). Default is 255 (opaque).
Returns:
hec_color(str): HEX color string.
"""
return "#{:02X}{:02X}{:02X}{:02X}".format(r, g, b, a)
@staticmethod
def validate_color(color: tuple | str) -> tuple | str:
"""
+1 -1
View File
@@ -150,7 +150,7 @@ class TypedForm(BECWidget, QWidget):
self.adjustSize()
def _new_grid_layout(self):
new_grid = QGridLayout()
new_grid = QGridLayout(self)
new_grid.setContentsMargins(0, 0, 0, 0)
return new_grid
@@ -1,53 +0,0 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from bec_lib.scan_args import ScanArgument
from pydantic import BaseModel
from pydantic_core import PydanticUndefined
from bec_widgets.utils.scan_arg_metadata import ui_config_from_metadata
NUMERIC_BOUND_KEYS = {"gt", "ge", "lt", "le"}
def pydantic_model_input_configs(model: type[BaseModel]) -> list[dict[str, Any]]:
"""Return scan-control-style field items for a Pydantic model."""
configs = []
for name, info in model.model_fields.items():
metadata: dict[str, Any] = {}
for entry in info.metadata:
if isinstance(entry, ScanArgument):
metadata.update(entry.model_dump(exclude_none=True))
continue
for key in NUMERIC_BOUND_KEYS:
value = getattr(entry, key, None)
if value is not None:
metadata.setdefault(key, value)
if isinstance(info.json_schema_extra, Mapping):
metadata.update(dict(info.json_schema_extra))
if info.description and metadata.get("description") is None:
metadata["description"] = info.description
default: Any
if info.default is not PydanticUndefined:
default = info.default
elif info.default_factory is not None:
default = info.get_default(call_default_factory=True)
else:
default = None
display_name = metadata.get("display_name") or info.title
if display_name is None:
display_name = name.replace("_", " ").capitalize()
item = ui_config_from_metadata(
name=name, metadata=metadata, default=default, display_name=display_name
)
item.update({key: value for key, value in metadata.items() if key not in item})
configs.append(item)
return configs
@@ -1,815 +0,0 @@
from __future__ import annotations
from types import NoneType
from typing import Any, Literal, get_args, get_origin
from bec_lib.device import DeviceBase, Signal
from pydantic import BaseModel, ValidationError
from pydantic.fields import FieldInfo
from qtpy.QtCore import Qt
from qtpy.QtCore import Signal as QtSignal
from qtpy.QtWidgets import (
QCheckBox,
QComboBox,
QDoubleSpinBox,
QFormLayout,
QHBoxLayout,
QLineEdit,
QSpinBox,
QWidget,
)
from bec_widgets.utils.forms_from_types.pydantic_model_info_adapter import (
NUMERIC_BOUND_KEYS,
pydantic_model_input_configs,
)
from bec_widgets.utils.scan_arg_metadata import (
apply_numeric_limits,
apply_numeric_precision,
apply_unit_metadata,
device_units,
)
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
from bec_widgets.widgets.utility.spinbox.decimal_spinbox import BECSpinBox
class OptionalValueWidget(QWidget):
"""Wrap a value widget with an enable checkbox for optional Pydantic fields.
Attributes:
value_changed: Signal emitted with the current value whenever the checkbox
state or wrapped widget value changes.
"""
value_changed = QtSignal(object)
def __init__(self, value_widget: QWidget, parent: QWidget | None = None) -> None:
"""Create an optional-value wrapper.
Args:
value_widget: Input widget used when the optional value is enabled.
parent: Optional parent widget.
"""
super().__init__(parent=parent)
self._value_widget = value_widget
self._checkbox = QCheckBox(self)
self._checkbox.setToolTip("Enable value")
self._value_widget.setParent(self)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
layout.addWidget(self._checkbox)
layout.addWidget(self._value_widget, 1)
self._checkbox.toggled.connect(self._on_enabled_changed)
WidgetIO.connect_widget_change_signal(self._value_widget, self._emit_current_value)
self._on_enabled_changed(False)
@property
def value_widget(self) -> QWidget:
"""Return the wrapped input widget.
Returns:
The widget that edits the non-``None`` value.
"""
return self._value_widget
@property
def checkbox(self) -> QCheckBox:
"""Return the checkbox controlling whether the value is enabled.
Returns:
The enable checkbox.
"""
return self._checkbox
def value(self) -> Any:
"""Return the current optional value.
Returns:
``None`` when the checkbox is unchecked; otherwise the wrapped widget value.
"""
if not self._checkbox.isChecked():
return None
return WidgetIO.get_value(self._value_widget)
def set_value(self, value: Any) -> None:
"""Set the optional value.
Args:
value: Value to set on the wrapped widget. ``None`` disables the value.
"""
enabled = value is not None
self._checkbox.setChecked(enabled)
self._value_widget.setEnabled(enabled)
if enabled:
WidgetIO.set_value(self._value_widget, value)
def _on_enabled_changed(self, enabled: bool) -> None:
self._value_widget.setEnabled(enabled)
self.value_changed.emit(self.value())
def _emit_current_value(self, *_args) -> None:
self.value_changed.emit(self.value())
class PydanticWidgetForm(QWidget):
"""Generate a Qt form from a Pydantic model.
The form maps Pydantic field annotations to Qt widgets, applies supported
field metadata, and exposes typed and raw data accessors for the generated
fields.
Attributes:
changed: Signal emitted whenever a generated input widget changes.
validity_changed: Signal emitted by :meth:`validate` with the current
validation result.
"""
changed = QtSignal()
validity_changed = QtSignal(bool)
def __init__(
self,
model: type[BaseModel],
parent: QWidget | None = None,
*,
data: BaseModel | dict[str, Any] | None = None,
read_only_fields: set[str] | None = None,
client=None,
) -> None:
"""Create a generated form for a Pydantic model.
Args:
model: Pydantic model class used to generate fields and validate data.
parent: Optional parent widget.
data: Optional initial model instance or raw field-value mapping.
read_only_fields: Field names that should be displayed but not editable.
client: Optional BEC client passed to domain-specific widgets such as
device and signal combo boxes.
"""
super().__init__(parent=parent)
self._model = model
self._client = client
self._read_only_fields = set(read_only_fields or set())
self._widgets: dict[str, QWidget] = {}
self._field_configs: dict[str, dict[str, Any]] = {}
self._baseline: dict[str, Any] = {}
self._layout = QFormLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.setHorizontalSpacing(10)
self._layout.setVerticalSpacing(8)
self._layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
self._layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
self.setLayout(self._layout)
self._populate()
if data is not None:
self.set_data(data)
self.mark_clean()
@property
def model(self) -> type[BaseModel]:
"""Return the active Pydantic model class.
Returns:
The model class currently used by this form.
"""
return self._model
@property
def widgets(self) -> dict[str, QWidget]:
"""Return generated field widgets keyed by model field name.
Returns:
A shallow copy of the field-widget mapping. Optional fields return
their outer :class:`OptionalValueWidget`.
"""
return dict(self._widgets)
def field_widget(self, name: str) -> QWidget:
"""Return the generated widget for a field.
Args:
name: Model field name.
Returns:
The generated field widget. Optional fields return their outer
:class:`OptionalValueWidget`.
Raises:
KeyError: If no widget exists for ``name``.
"""
return self._widgets[name]
def input_widget(self, name: str) -> QWidget:
"""Return the direct input widget for a field.
Args:
name: Model field name.
Returns:
The editable input widget. Optional fields return the wrapped value
widget instead of the outer optional wrapper.
Raises:
KeyError: If no widget exists for ``name``.
"""
widget = self._widgets[name]
if isinstance(widget, OptionalValueWidget):
return widget.value_widget
return widget
def input_widgets(self) -> dict[str, QWidget]:
"""Return direct input widgets keyed by model field name.
Returns:
Mapping of field names to editable input widgets.
"""
return {name: self.input_widget(name) for name in self._widgets}
def input_widgets_by_type(self, widget_type: type[QWidget]) -> list[QWidget]:
"""Return direct input widgets matching a widget type.
Args:
widget_type: Qt widget class to match with ``isinstance``.
Returns:
List of input widgets matching ``widget_type``.
"""
return [
widget for widget in self.input_widgets().values() if isinstance(widget, widget_type)
]
def set_model(self, model: type[BaseModel], data: dict[str, Any] | None = None) -> None:
"""Replace the active model and rebuild the form.
Args:
model: New Pydantic model class.
data: Optional initial data for the new model. When omitted, values
from fields shared with the previous model are preserved.
"""
old_data = self.raw_data()
self.cleanup()
self._model = model
self._populate()
if data is None:
data = {key: value for key, value in old_data.items() if key in model.model_fields}
self.set_partial_data(data)
self.mark_clean()
def set_data(self, data: BaseModel | dict[str, Any]) -> None:
"""Set form values from a model instance or mapping.
Args:
data: Pydantic model instance or raw field-value mapping.
"""
values = data.model_dump() if isinstance(data, BaseModel) else dict(data)
self.set_partial_data(values)
def set_partial_data(self, data: dict[str, Any]) -> None:
"""Set values for fields present in the form.
Unknown keys are ignored, which allows callers to pass larger model
dumps or backend payloads safely.
Args:
data: Field-value mapping to apply.
"""
for name, value in data.items():
if name not in self._widgets:
continue
self._set_widget_value(name, value)
self._refresh_reference_units()
self.changed.emit()
def raw_data(self) -> dict[str, Any]:
"""Return current widget values without Pydantic validation.
Returns:
Mapping of model field names to raw widget values.
"""
return {name: self._read_widget_value(name) for name in self._widgets}
def get_data(self) -> dict[str, Any]:
"""Return current data after Pydantic validation.
Returns:
Validated model data as a dictionary.
Raises:
ValidationError: If Pydantic validation fails.
ValueError: If domain widget validation fails.
"""
return self.model_instance().model_dump()
def model_instance(self) -> BaseModel:
"""Return the current values as a Pydantic model instance.
Returns:
Validated instance of the active model class.
Raises:
ValidationError: If Pydantic validation fails.
ValueError: If domain widget validation fails.
"""
self._validate_domain_widgets()
return self._model.model_validate(self.raw_data())
def validate(self) -> bool:
"""Validate the current form values.
Returns:
``True`` when current values validate successfully, otherwise ``False``.
"""
try:
self.get_data()
except (ValidationError, ValueError):
self.validity_changed.emit(False)
return False
self.validity_changed.emit(True)
return True
def dirty_fields(self) -> set[str]:
"""Return fields whose raw values differ from the clean baseline.
Returns:
Set of dirty field names.
"""
current = self.raw_data()
fields = set(current) | set(self._baseline)
return {field for field in fields if current.get(field) != self._baseline.get(field)}
def mark_clean(self) -> None:
"""Store the current raw values as the clean baseline."""
self._baseline = self.raw_data()
def reset_to_baseline(self) -> None:
"""Restore the form values to the current clean baseline."""
self.set_partial_data(self._baseline)
def editable_data(self) -> dict[str, Any]:
"""Return validated data excluding read-only fields.
Returns:
Validated editable field values.
Raises:
ValidationError: If Pydantic validation fails.
ValueError: If domain widget validation fails.
"""
return {
key: value
for key, value in self.get_data().items()
if key not in self._read_only_fields
}
def raw_editable_data(self) -> dict[str, Any]:
"""Return raw widget data excluding read-only fields.
Returns:
Raw editable field values.
"""
return {
key: value
for key, value in self.raw_data().items()
if key not in self._read_only_fields
}
def cleanup(self) -> None:
"""Close and schedule deletion of all generated field widgets."""
while self._layout.rowCount():
row = self._layout.takeRow(0)
for item in (row.labelItem, row.fieldItem):
widget = item.widget() if item is not None else None
if widget is not None:
widget.close()
# Detach before deleteLater: a child pending deletion that still has a
# signal connection into this form crashes if the form is garbage
# collected before the deferred delete is processed.
widget.setParent(None)
widget.deleteLater()
self._widgets.clear()
self._field_configs.clear()
def closeEvent(self, event) -> None: # noqa: N802
self.cleanup()
super().closeEvent(event)
def _populate(self) -> None:
for config in pydantic_model_input_configs(self._model):
name = config["name"]
info = self._model.model_fields[name]
widget = self._create_widget(name, info)
label_text = config["display_name"]
self._layout.addRow(label_text, widget)
label = self._layout.labelForField(widget)
if label is not None:
label.setProperty("_model_field_name", name)
if config.get("tooltip") and label is not None:
label.setToolTip(config["tooltip"])
widget.setEnabled(name not in self._read_only_fields)
self._widgets[name] = widget
self._field_configs[name] = config
self._set_widget_value(name, config["default"])
self._apply_field_metadata(name)
self._connect_widget(widget)
self._connect_device_signal_widgets()
self._connect_reference_unit_widgets()
self._refresh_reference_units()
def _create_widget(self, name: str, info: FieldInfo) -> QWidget:
annotation = info.annotation
args = get_args(annotation)
optional = NoneType in args
non_none_args = tuple(arg for arg in args if arg is not NoneType)
value_annotation = non_none_args[0] if len(non_none_args) == 1 else annotation
widget = self._create_value_widget(name, value_annotation)
numeric = value_annotation in (int, float) or any(
arg in (int, float) for arg in get_args(value_annotation)
)
if optional and (numeric or value_annotation is bool):
return OptionalValueWidget(widget, parent=self)
return widget
def _create_value_widget(self, name: str, annotation: Any) -> QWidget:
args = get_args(annotation)
if (
isinstance(annotation, type)
and issubclass(annotation, Signal)
or any(isinstance(arg, type) and issubclass(arg, Signal) for arg in args)
):
return SignalComboBox(
parent=self,
client=self._client,
require_device=self._model_has_device_field(),
arg_name=name,
)
if (
isinstance(annotation, type)
and issubclass(annotation, DeviceBase)
or any(isinstance(arg, type) and issubclass(arg, DeviceBase) for arg in args)
):
return DeviceComboBox(parent=self, client=self._client, arg_name=name)
if get_origin(annotation) is Literal:
widget = QComboBox(self)
widget.addItems([str(value) for value in get_args(annotation)])
return widget
if annotation is bool:
return QCheckBox(self)
if annotation is int:
spin_box = QSpinBox(self)
spin_box.setRange(-2147483647, 2147483647)
return spin_box
if annotation is float:
spin_box = BECSpinBox(self)
spin_box.setRange(-1_000_000_000, 1_000_000_000)
return spin_box
return QLineEdit(self)
def _apply_field_metadata(self, name: str) -> None:
config = self._field_configs[name]
field_widget = self._widgets[name]
input_widget = self.input_widget(name)
if config.get("precision") is not None:
apply_numeric_precision(input_widget, config)
if any(config.get(key) is not None for key in NUMERIC_BOUND_KEYS):
apply_numeric_limits(input_widget, config)
apply_unit_metadata(field_widget, config)
if input_widget is not field_widget:
apply_unit_metadata(input_widget, config)
def _connect_widget(self, widget: QWidget) -> None:
if isinstance(widget, OptionalValueWidget):
widget.value_changed.connect(lambda _value: self.changed.emit())
return
WidgetIO.connect_widget_change_signal(widget, lambda *_args: self.changed.emit())
def _connect_device_signal_widgets(self) -> None:
devices = [
widget for widget in self._widgets.values() if isinstance(widget, DeviceComboBox)
]
signals = [
widget for widget in self._widgets.values() if isinstance(widget, SignalComboBox)
]
if not devices or not signals:
return
device_widget = devices[0]
for signal_widget in signals:
device_widget.device_selected.connect(signal_widget.set_device)
device_widget.device_reset.connect(lambda w=signal_widget: w.set_device(None))
if device_widget.currentText().strip():
signal_widget.set_device(device_widget.currentText().strip())
def _connect_reference_unit_widgets(self) -> None:
for name, widget in self.input_widgets().items():
if not isinstance(widget, DeviceComboBox):
continue
widget.device_selected.connect(
lambda _device_name, field_name=name: self._update_reference_units(field_name)
)
widget.device_reset.connect(
lambda field_name=name: self._apply_reference_units(field_name, None)
)
widget.currentTextChanged.connect(
lambda text, field_name=name: self._handle_reference_device_text(field_name, text)
)
def _refresh_reference_units(self) -> None:
for name, widget in self.input_widgets().items():
if isinstance(widget, DeviceComboBox):
self._update_reference_units(name)
def _update_reference_units(self, source_name: str) -> None:
widget = self.input_widget(source_name)
if not isinstance(widget, DeviceComboBox) or not widget.is_valid_input:
self._apply_reference_units(source_name, None)
return
self._apply_reference_units(source_name, device_units(widget.get_current_device()))
def _apply_reference_units(self, source_name: str, units: str | None) -> None:
for field_name, config in self._field_configs.items():
if config.get("reference_units") != source_name:
continue
field_widget = self.field_widget(field_name)
input_widget = self.input_widget(field_name)
apply_unit_metadata(field_widget, config, units)
if input_widget is not field_widget:
apply_unit_metadata(input_widget, config, units)
def _handle_reference_device_text(self, source_name: str, device_name: str) -> None:
widget = self.input_widget(source_name)
if isinstance(widget, DeviceComboBox) and not widget.validate_device(device_name):
self._apply_reference_units(source_name, None)
def _validate_domain_widgets(self) -> None:
for widget in self._widgets.values():
if isinstance(widget, DeviceComboBox):
device = widget.currentText().strip()
if not device:
raise ValueError("Device is required.")
if not widget.is_valid_input:
raise ValueError(f"Device '{device}' is not available.")
if isinstance(widget, SignalComboBox):
signal = widget.get_signal_name().strip()
if signal and not widget.is_valid_input:
raise ValueError(f"Signal '{signal}' is not available.")
def _read_widget_value(self, name: str) -> Any:
widget = self._widgets[name]
info = self._model.model_fields[name]
if isinstance(widget, OptionalValueWidget):
return widget.value()
if isinstance(widget, QLineEdit):
value = WidgetIO.get_value(widget)
return None if NoneType in get_args(info.annotation) and value == "" else value
if isinstance(widget, QComboBox) and get_origin(info.annotation) is Literal:
return WidgetIO.get_value(widget, as_string=True)
return WidgetIO.get_value(widget)
def _set_widget_value(self, name: str, value: Any) -> None:
widget = self._widgets[name]
if isinstance(widget, OptionalValueWidget):
widget.set_value(value)
return
if value is None:
if isinstance(widget, QLineEdit):
value = ""
elif isinstance(widget, QCheckBox):
value = False
elif isinstance(widget, (QSpinBox, QDoubleSpinBox)):
value = 0
WidgetIO.set_value(widget, value)
def _model_has_device_field(self) -> bool:
for field in self._model.model_fields.values():
annotation = field.annotation
args = get_args(annotation)
has_device = (
isinstance(annotation, type)
and issubclass(annotation, DeviceBase)
or any(isinstance(arg, type) and issubclass(arg, DeviceBase) for arg in args)
)
has_signal = (
isinstance(annotation, type)
and issubclass(annotation, Signal)
or any(isinstance(arg, type) and issubclass(arg, Signal) for arg in args)
)
if has_device and not has_signal:
return True
return False
if __name__ == "__main__": # pragma: no cover
import json
import sys
from bec_lib.scan_args import ScanArgument
from pydantic import Field
from qtpy.QtWidgets import QApplication, QLabel, QPushButton, QTabWidget, QTextEdit, QVBoxLayout
from bec_widgets.utils.colors import apply_theme
class BasicScanConfig(BaseModel):
"""Plain Pydantic fields without GUI metadata."""
sample_name: str
enabled: bool = True
repeats: int = 3
class LimitConfig(BaseModel):
"""Normal Pydantic Field metadata."""
mode: Literal["monitor", "scan", "calibration"] = "scan"
low_limit: (
float | None
) # example of the field without additional metadata, still works in form
high_limit: float | None = Field(
default=10.0,
title="High limit",
description="Optional upper allowed value.",
json_schema_extra={"precision": 4},
)
tolerance: float = Field(
default=0.1,
title="Tolerance",
description="Warning tolerance around configured limits.",
json_schema_extra={"precision": 4},
)
class ScanArgumentConfig(BaseModel):
"""ScanArgument metadata applied through Field extras."""
settling_time: float = Field(
default=0.0,
**ScanArgument(
display_name="Settling time",
description="Time to wait after moving.",
units="s",
precision=3,
ge=0,
).model_dump(),
)
frames: int = Field(
default=1,
**ScanArgument(
display_name="Frames", description="Number of frames per trigger.", ge=1
).model_dump(),
)
class DeviceSignalLimitsConfig(BaseModel):
"""Device, signal, and numeric fields whose units follow the selected device."""
model_config = {"arbitrary_types_allowed": True}
device: DeviceBase | str = Field(
default="",
**ScanArgument(display_name="Device", description="Positioner device.").model_dump(),
)
signal: Signal | str | None = Field(
default=None,
**ScanArgument(display_name="Signal", description="Device signal.").model_dump(),
)
low_limit: float | None = Field(
default=None,
**ScanArgument(
display_name="Low limit",
description="Optional lower limit.",
reference_units="device",
precision=4,
).model_dump(),
)
high_limit: float | None = Field(
default=None,
**ScanArgument(
display_name="High limit",
description="Optional upper limit.",
reference_units="device",
precision=4,
).model_dump(),
)
class DisplayConfig(BaseModel):
title: str | None = Field(
default=None, title="Title", description="Optional display title."
)
show_grid: bool = Field(default=True, title="Show grid")
refresh_interval: int = Field(
default=1000, title="Refresh interval", description="Refresh interval in milliseconds."
)
class DeviceAndSignalConfig(BaseModel):
model_config = {"arbitrary_types_allowed": True}
title: str | None = Field(
default=None, title="Title", description="Optional display title."
)
device: DeviceBase | str = Field(
default="", title="Device", description="BEC device selection."
)
signal: Signal | str | None = Field(
default=None,
title="Signal",
description="Signal selection scoped to the selected device.",
)
refresh_interval: int = Field(
default=1000, title="Refresh interval", description="Refresh interval in milliseconds."
)
class DeviceOnlyConfig(BaseModel):
model_config = {"arbitrary_types_allowed": True}
title: str | None = Field(
default=None, title="Title", description="Optional display title."
)
device: DeviceBase | str = Field(
default="", title="Device", description="BEC device selection."
)
refresh_interval: int = Field(
default=1000, title="Refresh interval", description="Refresh interval in milliseconds."
)
class SignalOnlyConfig(BaseModel):
model_config = {"arbitrary_types_allowed": True}
title: str | None = Field(
default=None, title="Title", description="Optional display title."
)
signal: Signal | str | None = Field(
default=None,
title="Signal",
description="Global BEC signal selection without a device field.",
)
refresh_interval: int = Field(
default=1000, title="Refresh interval", description="Refresh interval in milliseconds."
)
class ExampleWindow(QWidget):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("PydanticWidgetForm example")
self._tabs = QTabWidget(self)
self._output = QTextEdit(self)
self._output.setReadOnly(True)
self._output.setPlaceholderText("Validated form data appears here.")
self._forms: list[PydanticWidgetForm] = []
self._add_form("Basic", PydanticWidgetForm(BasicScanConfig))
self._add_form("Limits", PydanticWidgetForm(LimitConfig))
self._add_form("ScanArgument", PydanticWidgetForm(ScanArgumentConfig))
self._add_form("Display", PydanticWidgetForm(DisplayConfig))
self._add_form("Device + signal", PydanticWidgetForm(DeviceAndSignalConfig))
self._add_form("Device limits", PydanticWidgetForm(DeviceSignalLimitsConfig))
self._add_form("Device only", PydanticWidgetForm(DeviceOnlyConfig))
self._add_form("Signal only", PydanticWidgetForm(SignalOnlyConfig))
show_data = QPushButton("Show current tab data", self)
show_data.clicked.connect(self._show_current_data)
layout = QVBoxLayout(self)
layout.addWidget(QLabel("Generated forms from Pydantic models", self))
layout.addWidget(self._tabs)
layout.addWidget(show_data)
layout.addWidget(self._output)
def _add_form(self, title: str, form: PydanticWidgetForm) -> None:
form.changed.connect(lambda _form=form: self._on_form_changed(_form))
self._forms.append(form)
self._tabs.addTab(form, title)
def _show_current_data(self, _checked: bool = False, *, validate: bool = True) -> None:
form = self._forms[self._tabs.currentIndex()]
if validate:
try:
data = form.get_data()
except (ValidationError, ValueError) as exc:
self._output.setPlainText(str(exc))
return
key = "data"
else:
data = form.raw_data()
key = "raw_data"
self._output.setPlainText(
json.dumps(
{key: data, "dirty_fields": sorted(form.dirty_fields())}, indent=2, default=str
)
)
def _on_form_changed(self, form: PydanticWidgetForm) -> None:
if form is self._forms[self._tabs.currentIndex()]:
self._show_current_data(validate=False)
app = QApplication(sys.argv)
apply_theme("dark")
window = ExampleWindow()
window.show()
sys.exit(app.exec())
-16
View File
@@ -1,16 +0,0 @@
from __future__ import annotations
def elapsed_seconds(start: float | int | None, stop: float) -> float | None:
if start is None:
return None
try:
return max(0.0, stop - float(start))
except (TypeError, ValueError):
return None
def format_elapsed(elapsed: float | None) -> str:
if elapsed is None:
return "unknown"
return f"{elapsed:.3f}"
+9 -112
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import functools
import time
import traceback
import types
from contextlib import contextmanager
@@ -12,14 +11,13 @@ from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import
from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import QApplication, QWidget
from qtpy.QtWidgets import QWidget
from redis.exceptions import RedisError
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import ErrorPopupUtility
from bec_widgets.utils.rpc_logging import elapsed_seconds, format_elapsed
from bec_widgets.utils.rpc_register import RPCRegister
from bec_widgets.utils.screen_utils import apply_window_geometry
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
@@ -117,107 +115,27 @@ class RPCServer:
if request_id is None:
logger.error("Received RPC instruction without request_id")
return
method = msg.get("action")
parameter = msg.get("parameter", {})
args = parameter.get("args", [])
kwargs = parameter.get("kwargs", {})
target_gui_id = parameter.get("gui_id")
sent_at = metadata.get("sent_at")
deadline = metadata.get("deadline")
timeout = metadata.get("timeout")
received_at = time.time()
receive_latency = elapsed_seconds(sent_at, received_at)
stale_on_receive = deadline is not None and received_at > deadline
logger.info(
"GUI RPC server received request "
f"request_id={request_id} method={method} gui_id={self.gui_id} "
f"target_gui_id={target_gui_id} timeout={timeout} "
f"receive_latency_s={format_elapsed(receive_latency)} "
f"stale_on_receive={stale_on_receive}"
)
if stale_on_receive:
logger.warning(
"GUI RPC server received request after client timeout deadline "
f"request_id={request_id} method={method} gui_id={self.gui_id} "
f"target_gui_id={target_gui_id} timeout={timeout} "
f"receive_latency_s={format_elapsed(receive_latency)}"
)
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
# Shutdown must acknowledge before teardown starts. The generic RPC path
# below publishes successful responses through QTimer.singleShot(0);
# for system.shutdown that would race with the queued app quit and
# dispatcher shutdown scheduled by _shutdown_gui_server().
if method == "system.shutdown":
execution_start = time.perf_counter()
try:
self.run_system_rpc(method, args, kwargs)
except Exception:
execution_duration = time.perf_counter() - execution_start
content = traceback.format_exc()
logger.error(
"GUI RPC server shutdown request failed "
f"request_id={request_id} method={method} gui_id={self.gui_id} "
f"execution_duration_s={execution_duration:.3f}\n{content}"
)
self.send_response(request_id, False, {"error": content})
else:
execution_duration = time.perf_counter() - execution_start
logger.info(
"GUI RPC server acknowledged shutdown request "
f"request_id={request_id} method={method} gui_id={self.gui_id} "
f"execution_duration_s={execution_duration:.3f}"
)
self.send_response(request_id, True, {"result": None})
return
execution_start = time.perf_counter()
with rpc_exception_hook(functools.partial(self.send_response, request_id, False)):
try:
method = msg["action"]
args = msg["parameter"].get("args", [])
kwargs = msg["parameter"].get("kwargs", {})
if method.startswith("system."):
res = self.run_system_rpc(method, args, kwargs)
else:
obj = self.get_object_from_config(parameter)
obj = self.get_object_from_config(msg["parameter"])
res = self.run_rpc(obj, method, args, kwargs)
except Exception:
execution_duration = time.perf_counter() - execution_start
content = traceback.format_exc()
logger.error(
"GUI RPC server execution failed "
f"request_id={request_id} method={method} gui_id={self.gui_id} "
f"target_gui_id={target_gui_id} execution_duration_s={execution_duration:.3f}\n"
f"{content}"
)
logger.error(f"Error while executing RPC instruction: {content}")
self.send_response(request_id, False, {"error": content})
else:
execution_duration = time.perf_counter() - execution_start
response_stale = deadline is not None and time.time() > deadline
logger.info(
"GUI RPC server executed request "
f"request_id={request_id} method={method} gui_id={self.gui_id} "
f"target_gui_id={target_gui_id} execution_duration_s={execution_duration:.3f} "
f"response_after_client_deadline={response_stale}"
)
if response_stale:
logger.warning(
"GUI RPC server response is late for client timeout "
f"request_id={request_id} method={method} gui_id={self.gui_id} "
f"target_gui_id={target_gui_id} timeout={timeout} "
f"execution_duration_s={execution_duration:.3f}"
)
logger.debug(f"RPC instruction executed successfully: {res}")
self._rpc_singleshot_repeats[request_id] = SingleshotRPCRepeat()
QTimer.singleShot(0, lambda: self.serialize_result_and_send(request_id, res))
def send_response(self, request_id: str, accepted: bool, msg: dict):
log_message = (
"GUI RPC server publishing response "
f"request_id={request_id} gui_id={self.gui_id} accepted={accepted}"
)
if accepted:
logger.info(log_message)
else:
logger.error(log_message)
self.client.connector.set_and_publish(
MessageEndpoints.gui_instruction_response(request_id),
messages.RequestResponseMessage(accepted=accepted, message=msg),
@@ -318,23 +236,10 @@ class RPCServer:
def run_system_rpc(self, method: str, args: list, kwargs: dict):
if method == "system.launch_dock_area":
return self._launch_dock_area(*args, **kwargs)
if method == "system.shutdown":
return self._shutdown_gui_server()
if method == "system.list_capabilities":
return {"system.launch_dock_area": True, "system.shutdown": True}
return {"system.launch_dock_area": True}
raise ValueError(f"Unknown system RPC method: {method}")
@staticmethod
def _shutdown_gui_server() -> None:
app = QApplication.instance()
if app is None:
return
gui_server = getattr(app, "gui_server", None)
if gui_server is not None and hasattr(gui_server, "request_shutdown"):
QTimer.singleShot(0, gui_server.request_shutdown)
return
QTimer.singleShot(0, app.quit)
@staticmethod
def _launch_dock_area(
name: str | None = None,
@@ -392,14 +297,7 @@ class RPCServer:
res = self.serialize_object(res)
except RegistryNotReadyError:
try:
repeat = self._rpc_singleshot_repeats[request_id]
repeat += retry_delay
logger.warning(
"GUI RPC result serialization delayed; retrying "
f"request_id={request_id} retry_delay_ms={retry_delay} "
f"accumulated_delay_ms={repeat.accumulated_delay} "
f"max_delay_ms={repeat.max_delay}"
)
self._rpc_singleshot_repeats[request_id] += retry_delay
QTimer.singleShot(
retry_delay, lambda: self.serialize_result_and_send(request_id, res)
)
@@ -509,9 +407,8 @@ class RPCServer:
container_proxy = parent.gui_id
else:
container_proxy = None
except Exception as e:
except Exception:
container_proxy = None
logger.error(f"Error while serializing RPC result: {e}")
if wait and not self.rpc_register.object_is_registered(connector):
raise RegistryNotReadyError(f"Connector {connector} not registered yet")
-155
View File
@@ -1,155 +0,0 @@
from __future__ import annotations
import re
from collections.abc import Mapping
from typing import Any
from bec_lib import bec_logger
from qtpy.QtWidgets import QDoubleSpinBox, QSpinBox, QWidget
logger = bec_logger.logger
UNIT_TOOLTIP_PREFIXES = ("Units:", "Units from:")
def format_display_name(name: str) -> str:
"""Convert a raw argument name into a user-facing label."""
parts = re.split(r"(_|\d+)", name)
return " ".join(part.capitalize() for part in parts if part.isalnum()).strip()
def resolve_tooltip(scan_argument: Mapping[str, Any]) -> str | None:
"""Resolve explicit tooltip text, falling back to the description."""
return scan_argument.get("tooltip") or scan_argument.get("description")
def ui_config_from_metadata(
name: str,
metadata: Mapping[str, Any],
*,
default: Any = None,
input_type: Any = None,
arg: bool = False,
display_name: str | None = None,
) -> dict[str, Any]:
"""Build the normalized scan-input item consumed by form widgets."""
return {
"arg": arg,
"name": name,
"type": input_type,
"display_name": display_name or metadata.get("display_name") or format_display_name(name),
"tooltip": resolve_tooltip(metadata),
"default": default,
"expert": metadata.get("expert", False),
"hidden": metadata.get("hidden", False),
"precision": metadata.get("precision"),
"units": metadata.get("units"),
"reference_units": metadata.get("reference_units"),
"reference_limits": metadata.get("reference_limits"),
"gt": metadata.get("gt"),
"ge": metadata.get("ge"),
"lt": metadata.get("lt"),
"le": metadata.get("le"),
"alternative_group": metadata.get("alternative_group"),
}
def unit_tooltip(item: Mapping[str, Any], units: str | None = None) -> str | None:
"""Build tooltip text from scan argument unit metadata."""
tooltip = item.get("tooltip")
reference_units = item.get("reference_units")
units = units or item.get("units")
tooltip_parts = [tooltip] if tooltip else []
if units:
tooltip_parts.append(f"Units: {units}")
elif reference_units:
tooltip_parts.append(f"Units from: {reference_units}")
if tooltip_parts:
return "\n".join(str(part) for part in tooltip_parts)
return None
def strip_unit_tooltip(tooltip: str) -> str:
"""Remove unit lines added by :func:`apply_unit_metadata`."""
return "\n".join(
line for line in tooltip.splitlines() if not line.startswith(UNIT_TOOLTIP_PREFIXES)
).strip()
def apply_unit_metadata(widget: QWidget, item: Mapping[str, Any], units: str | None = None) -> None:
"""Apply unit tooltip text and numeric suffix metadata to a widget."""
units = units or item.get("units")
tooltip = unit_tooltip(item, units)
existing_tooltip = strip_unit_tooltip(widget.toolTip())
base_tooltip = item.get("tooltip")
if base_tooltip and existing_tooltip == base_tooltip:
existing_tooltip = ""
if tooltip:
widget.setToolTip(f"{existing_tooltip}\n{tooltip}" if existing_tooltip else tooltip)
else:
widget.setToolTip(existing_tooltip)
if hasattr(widget, "setSuffix"):
widget.setSuffix(f" {units}" if units else "")
def device_units(device: object) -> str | None:
"""Return engineering units from a BEC device object when available."""
egu = getattr(device, "egu", None)
if not callable(egu):
return None
try:
return egu()
except Exception:
logger.exception("Failed to fetch engineering units from device %s", device)
return None
def apply_numeric_precision(widget: QWidget, item: Mapping[str, Any]) -> None:
"""Apply decimal precision metadata to spinboxes supporting ``setDecimals``."""
if not hasattr(widget, "setDecimals"):
return
precision = item.get("precision")
if precision is None:
return
try:
widget.setDecimals(max(0, int(precision)))
except (TypeError, ValueError):
logger.warning(
"Ignoring invalid precision %r for parameter %s", precision, item.get("name")
)
def apply_numeric_limits(widget: QWidget, item: Mapping[str, Any]) -> None:
"""Apply ``gt/ge/lt/le`` numeric bounds to Qt spinboxes."""
if isinstance(widget, QSpinBox) and not isinstance(widget, QDoubleSpinBox):
minimum = -2147483647
maximum = 2147483647
if item.get("ge") is not None:
minimum = int(item["ge"])
if item.get("gt") is not None:
minimum = int(item["gt"]) + 1
if item.get("le") is not None:
maximum = int(item["le"])
if item.get("lt") is not None:
maximum = int(item["lt"]) - 1
widget.setRange(minimum, maximum)
return
if isinstance(widget, QDoubleSpinBox):
minimum = -float("inf")
maximum = float("inf")
step = 10 ** (-widget.decimals())
if item.get("ge") is not None:
minimum = float(item["ge"])
if item.get("gt") is not None:
minimum = float(item["gt"]) + step
if item.get("le") is not None:
maximum = float(item["le"])
if item.get("lt") is not None:
maximum = float(item["lt"]) - step
widget.setRange(minimum, maximum)
-62
View File
@@ -99,45 +99,6 @@ class ComboBoxHandler(WidgetHandler):
widget.currentIndexChanged.connect(lambda idx, w=widget: slot(w, self.get_value(w)))
class DeviceComboBoxHandler(ComboBoxHandler):
"""Handler for BEC device comboboxes. The widget value is the device name."""
def get_value(self, widget, **kwargs) -> str:
return widget.currentText().strip()
def set_value(self, widget, value: str | None) -> None:
device = "" if value is None else str(value)
if not device:
widget.setCurrentText("")
return
widget.set_device(device)
if widget.currentText() != device:
widget.setCurrentText(device)
def connect_change_signal(self, widget, slot):
widget.currentTextChanged.connect(lambda text, w=widget: slot(w, text.strip()))
class SignalComboBoxHandler(ComboBoxHandler):
"""Handler for BEC signal comboboxes. The widget value is the signal object name."""
def get_value(self, widget, **kwargs) -> str | None:
signal = widget.get_signal_name().strip()
return signal or None
def set_value(self, widget, value: str | None) -> None:
signal = "" if value is None else str(value)
if not signal:
widget.setCurrentText("")
return
widget.set_signal(signal)
if widget.currentText() != signal and widget.get_signal_name() != signal:
widget.setCurrentText(signal)
def connect_change_signal(self, widget, slot):
widget.currentTextChanged.connect(lambda _text, w=widget: slot(w, self.get_value(w)))
class TableWidgetHandler(WidgetHandler):
"""Handler for QTableWidget widgets."""
@@ -246,28 +207,6 @@ class WidgetIO:
ToggleSwitch: ToggleSwitchHandler,
QSlider: SlideHandler,
}
_deferred_handlers_registered = False
@classmethod
def _register_deferred_handlers(cls) -> None:
"""
Register handlers for widgets that import this module themselves and therefore
cannot be imported here at module level without a circular import. The import is
deferred to the first handler lookup, when all modules are fully initialized.
"""
if cls._deferred_handlers_registered:
return
cls._deferred_handlers_registered = True
# pylint: disable=import-outside-toplevel
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
DeviceComboBox,
)
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import (
SignalComboBox,
)
cls._handlers[DeviceComboBox] = DeviceComboBoxHandler
cls._handlers[SignalComboBox] = SignalComboBoxHandler
@staticmethod
def get_value(widget, ignore_errors=False, **kwargs):
@@ -343,7 +282,6 @@ class WidgetIO:
Returns:
handler_class: The handler class if found, otherwise None.
"""
WidgetIO._register_deferred_handlers()
for base in type(widget).__mro__:
if base in WidgetIO._handlers:
return WidgetIO._handlers[base]
@@ -385,11 +385,6 @@ class BECDockArea(DockAreaWidget):
"bec_shell": (widget_icons["BECShell"], "Add BEC Shell", "BECShell"),
"sbb_monitor": (widget_icons["SBBMonitor"], "Add SBB Monitor", "SBBMonitor"),
"log_panel": (widget_icons["LogPanel"], "Add LogPanel", "LogPanel"),
"beamline_state_manager": (
widget_icons["BeamlineStateManager"],
"Add Beamline State Manager",
"BeamlineStateManager",
),
}
# Create expandable menu actions (original behavior)
@@ -11,6 +11,7 @@ Intended for use in desktop applications to provide user feedback, warnings, and
from __future__ import annotations
import json
import sys
from datetime import datetime
from enum import Enum
@@ -20,7 +21,6 @@ from uuid import uuid4
import pyqtgraph as pg
from bec_lib.alarm_handler import Alarms # external enum
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.messages import ErrorInfo
from bec_qthemes import material_icon
from qtpy import QtCore, QtGui, QtWidgets
@@ -29,11 +29,9 @@ from qtpy.QtWidgets import QApplication, QFrame, QMainWindow, QScrollArea, QWidg
from bec_widgets import SafeProperty, SafeSlot
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.colors import apply_theme, get_theme_name
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.widget_io import WidgetIO
logger = bec_logger.logger
class SeverityKind(str, Enum):
INFO = "info"
@@ -150,14 +148,11 @@ class NotificationToast(QFrame):
body_lbl.setWordWrap(True)
self.time_lbl = QtWidgets.QLabel()
self._showing_absolute = False
self._update_relative_time()
# enable absolute timestamp on hover
self.time_lbl.setCursor(QtCore.Qt.PointingHandCursor)
self.time_lbl.installEventFilter(self)
# shared ID assigned by NotificationCentre.add_notification
self.notification_id: str | None = None
self._showing_absolute = False
self.close_btn = QtWidgets.QPushButton("")
self.close_btn.setObjectName("toastCloseBtn")
@@ -250,22 +245,21 @@ class NotificationToast(QFrame):
# lifetime progress animation
self._lifetime = max(0, lifetime_ms) # 0 → never expire
self._progress_anim: QtCore.QPropertyAnimation | None = None
# flag to indicate this toast has fully expired (progress bar finished)
self._expired = False
if self._lifetime > 0:
self._start_progress_animation()
else:
self.progress.hide()
# flag to indicate this toast has fully expired (progress bar finished)
self._expired = False
# ------------------------------------------------------------------
def _connect_to_theme_change(self):
"""Connect this toast to the global themeupdated signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme"):
qapp.theme.theme_changed.connect(self.apply_theme)
else:
logger.warning("Theme could not be fetched form QApplication object.")
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self.apply_theme)
# helper methods -----------------------------------------------------
def _current_inner_width(self) -> int:
@@ -339,6 +333,9 @@ class NotificationToast(QFrame):
}}
""")
self.apply_theme(self._theme)
# keep injected gradient in sync
if getattr(self, "_hg_enabled", False):
self._hg_cols[0] = self._accent_color
@SafeProperty(str)
def traceback(self):
@@ -357,9 +354,11 @@ class NotificationToast(QFrame):
Args:
theme(str | None): "light" or "dark". If None, auto-detects from QApplication.
"""
theme = str(theme or get_theme_name()).lower()
if theme not in {"light", "dark"}:
theme = "dark"
# determine effective theme
if theme is None:
app = QApplication.instance()
theme = getattr(getattr(app, "theme", None), "theme", "dark")
theme = theme.lower()
self._theme = theme
palette = DARK_PALETTE if theme == "dark" else LIGHT_PALETTE
@@ -404,18 +403,11 @@ class NotificationToast(QFrame):
#NotificationToast QPushButton:hover {{ color: {btn_hover}; }}
""")
# traceback panel colours
if theme == "dark": # FIXME Unify stylesheets and move them to BECQThemes issue #1189
trace_bg = "#1e1e1e"
trace_fg = palette["body"]
trace_border = "rgba(255,255,255,48)"
else:
trace_bg = "#ffffff"
trace_fg = palette["body"]
trace_border = "rgba(15,23,42,54)"
trace_bg = "#1e1e1e" if theme == "dark" else "#f0f0f0"
self.trace_view.setStyleSheet(f"""
background:{trace_bg};
color:{trace_fg};
border: 1px solid {trace_border};
color:{palette['body']};
border:none;
border-radius:8px;
""")
@@ -446,8 +438,8 @@ class NotificationToast(QFrame):
}}
""")
self._accent_alpha = 6 if theme == "light" else 60
self._gradient_width_factor = 1.0 if theme == "light" else 0.70
# stronger accent wash in light mode, slightly stronger in dark too
self._accent_alpha = 110 if theme == "light" else 60
self.update()
########################################
@@ -455,7 +447,7 @@ class NotificationToast(QFrame):
########################################
def _update_relative_time(self) -> None:
if self._showing_absolute:
if getattr(self, "_showing_absolute", False):
return # don't overwrite while user is viewing absolute time
seconds = int((datetime.now() - self.created).total_seconds())
if seconds < 10:
@@ -479,8 +471,6 @@ class NotificationToast(QFrame):
# Event Filters
########################################
def eventFilter(self, watched, event):
if not isinstance(event, QtCore.QEvent):
return False
# timestamp label → toggle absolute time
if watched is self.time_lbl:
if event.type() == QtCore.QEvent.Enter and not self._showing_absolute:
@@ -496,7 +486,7 @@ class NotificationToast(QFrame):
Pause the countdown while the cursor is over the toast, and reset the
elapsed time and progress bar to full width.
"""
if self._expired:
if getattr(self, "_expired", False):
return super().enterEvent(event)
self._hover = True
if self._progress_anim is not None:
@@ -510,10 +500,10 @@ class NotificationToast(QFrame):
Resume the countdown when the cursor leaves, continuing from the
paused progress rather than restarting.
"""
if self._expired:
if getattr(self, "_expired", False):
return super().leaveEvent(event)
self._hover = False
if self._lifetime > 0:
if self._lifetime > 0 and not self._expired:
self._start_progress_animation()
super().leaveEvent(event)
@@ -529,11 +519,11 @@ class NotificationToast(QFrame):
painter.fillPath(path, self._base_color)
# accent gradient, fades to transparent
grad = QtGui.QLinearGradient(0, 0, self.width() * self._gradient_width_factor, 0)
grad = QtGui.QLinearGradient(0, 0, self.width() * 0.7, 0)
accent = QtGui.QColor(self._accent_color)
if self._theme == "light":
if getattr(self, "_theme", "dark") == "light":
accent = accent.darker(115)
accent.setAlpha(self._accent_alpha)
accent.setAlpha(getattr(self, "_accent_alpha", 50))
grad.setColorAt(0.0, accent)
fade = QtGui.QColor(self._accent_color)
fade.setAlpha(0)
@@ -553,7 +543,7 @@ class NotificationToast(QFrame):
def close(self) -> None:
self.closed.emit()
self.time_lbl.removeEventFilter(self)
QtWidgets.QApplication.instance().removeEventFilter(self)
super().close() # this will remove the widget from its parent
@@ -587,7 +577,8 @@ class NotificationCentre(QScrollArea):
def __init__(self, parent=None, *, fixed_width: int = 420, margin: int = 16):
super().__init__(parent=parent)
self.setObjectName("NotificationCentre")
self._theme = get_theme_name()
app = QApplication.instance()
self._theme = getattr(getattr(app, "theme", None), "theme", "dark").lower()
self.setWidgetResizable(True)
# transparent background so only the toast cards are visible
@@ -682,10 +673,8 @@ class NotificationCentre(QScrollArea):
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme"):
qapp.theme.theme_changed.connect(self.apply_theme)
else:
logger.warning("Theme could not be fetched form QApplication object.")
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self.apply_theme)
# public API
def add_notification(
@@ -752,7 +741,7 @@ class NotificationCentre(QScrollArea):
def remove_notification(self, notification_id: str) -> None:
"""Close a specific notification in this centre if present."""
for toast in list(self.toasts):
if toast.notification_id == notification_id:
if getattr(toast, "notification_id", None) == notification_id:
self._hide_notification(toast)
# ------------------------------------------------------------------
@@ -899,8 +888,6 @@ class NotificationCentre(QScrollArea):
self.setFixedHeight(min(content_h, avail))
def eventFilter(self, watched, event):
if not isinstance(event, QtCore.QEvent):
return False
if watched is self.parent() and event.type() == QtCore.QEvent.Resize:
self._adjust_height()
return super().eventFilter(watched, event)
@@ -4,7 +4,7 @@ import os
from bec_lib import bec_logger
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import QSize, Qt, QTimer
from qtpy.QtCore import QEvent, QSize, Qt, QTimer
from qtpy.QtGui import QAction, QActionGroup, QIcon
from qtpy.QtWidgets import (
QApplication,
@@ -46,8 +46,8 @@ logger = bec_logger.logger
class BECMainWindow(BECWidget, QMainWindow):
RPC = True
PLUGIN = True
SCAN_PROGRESS_WIDTH = 120 # px
SCAN_PROGRESS_HEIGHT = 20 # px
SCAN_PROGRESS_WIDTH = 100 # px
SCAN_PROGRESS_HEIGHT = 12 # px
def __init__(self, parent=None, window_title: str = "BEC", **kwargs):
super().__init__(parent=parent, **kwargs)
@@ -197,11 +197,7 @@ class BECMainWindow(BECWidget, QMainWindow):
# Setting HoverWidget for the scan progress bar - minimal and full version
self._scan_progress_bar_simple = ScanProgressBar(
self,
one_line_design=True,
rpc_exposed=False,
rpc_passthrough_children=False,
enable_dynamic_stylesheet=True,
self, one_line_design=True, rpc_exposed=False, rpc_passthrough_children=False
)
self._scan_progress_bar_simple.show_elapsed_time = False
self._scan_progress_bar_simple.show_remaining_time = False
@@ -209,9 +205,8 @@ class BECMainWindow(BECWidget, QMainWindow):
self._scan_progress_bar_simple.progressbar.label_template = ""
self._scan_progress_bar_simple.progressbar.setFixedHeight(self.SCAN_PROGRESS_HEIGHT)
self._scan_progress_bar_simple.progressbar.setFixedWidth(self.SCAN_PROGRESS_WIDTH)
# This one do not need dynamic styling on hover ScanProgressBar since user will hover on it probably later, when progress bar is big enough
self._scan_progress_bar_full = ScanProgressBar(
self, rpc_exposed=False, rpc_passthrough_children=False, enable_dynamic_stylesheet=False
self, rpc_exposed=False, rpc_passthrough_children=False
)
self._scan_progress_hover = HoverWidget(
self, simple=self._scan_progress_bar_simple, full=self._scan_progress_bar_full
@@ -238,8 +233,8 @@ class BECMainWindow(BECWidget, QMainWindow):
# The actual line
line = QFrame()
line.setFrameShape(QFrame.Shape.VLine)
line.setFrameShadow(QFrame.Shadow.Sunken)
line.setFrameShape(QFrame.VLine)
line.setFrameShadow(QFrame.Sunken)
line.setFixedHeight(status_bar.sizeHint().height() - 2)
# Wrapper to center the line vertically -> work around for QFrame not being able to center itself
@@ -247,7 +242,7 @@ class BECMainWindow(BECWidget, QMainWindow):
vbox = QVBoxLayout(wrapper)
vbox.setContentsMargins(0, 0, 0, 0)
vbox.addStretch()
vbox.addWidget(line, alignment=Qt.AlignmentFlag.AlignHCenter)
vbox.addWidget(line, alignment=Qt.AlignHCenter)
vbox.addStretch()
wrapper.setFixedWidth(line.sizeHint().width())
@@ -417,6 +412,11 @@ class BECMainWindow(BECWidget, QMainWindow):
"""
apply_theme(theme) # emits theme_updated and applies palette globally
def event(self, event):
if event.type() == QEvent.Type.StatusTip:
return True
return super().event(event)
def _show_widget_hierarchy_dialog(self):
if self._widget_hierarchy_dialog is None:
dialog = WidgetHierarchyDialog(root_widget=None, parent=self)
@@ -1,6 +1,3 @@
from __future__ import annotations
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QToolButton, QWidget
@@ -8,8 +5,6 @@ from qtpy.QtWidgets import QHBoxLayout, QPushButton, QToolButton, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
class AbortButton(BECWidget, QWidget):
"""A button that abort the scan."""
@@ -60,7 +55,7 @@ class AbortButton(BECWidget, QWidget):
scan_id(str|None): The scan id to abort. If None, the current scan will be aborted.
"""
if self.scan_id is not None:
logger.info(f"Aborting scan with scan_id: {self.scan_id}")
print(f"Aborting scan with scan_id: {self.scan_id}")
self.queue.request_scan_abortion(scan_id=self.scan_id)
else:
self.queue.request_scan_abortion()
@@ -429,7 +429,7 @@ class PositionerBox2D(PositionerBoxBase):
@SafeSlot()
def on_stop(self):
self._stop_device([self.device_hor, self.device_ver])
self._stop_device(f"{self.device_hor} or {self.device_ver}")
@SafeProperty(float)
def step_size_hor(self):
@@ -1,10 +1,11 @@
import uuid
from abc import abstractmethod
from typing import Callable, Sequence, TypedDict
from typing import Callable, TypedDict
from bec_lib.device import Positioner
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.messages import VariableMessage
from bec_lib.messages import ScanQueueMessage
from qtpy.QtWidgets import (
QDialog,
QDoubleSpinBox,
@@ -115,16 +116,17 @@ class PositionerBoxBase(BECWidget, QWidget):
else:
ui["units"].setVisible(False)
def _stop_device(self, device: str | Sequence[str]):
def _stop_device(self, device: str):
"""Stop call"""
devices = [device] if isinstance(device, str) else list(device)
devices = [dev for dev in devices if dev]
if not devices:
logger.warning("Stop requested without a valid device.")
return
msg = VariableMessage(value=devices)
self.client.connector.send(MessageEndpoints.stop_devices(), msg)
request_id = str(uuid.uuid4())
params = {"device": device, "rpc_id": request_id, "func": "stop", "args": [], "kwargs": {}}
msg = ScanQueueMessage(
scan_type="device_rpc",
parameter=params,
queue="emergency",
metadata={"RID": request_id, "response": False},
)
self.client.connector.send(MessageEndpoints.scan_queue_request(self.client.username), msg)
# pylint: disable=unused-argument
def _on_device_readback(
@@ -9,7 +9,7 @@ from bec_lib.device import ComputedSignal, Device, Positioner, ReadoutPriority
from bec_lib.device import Signal as BECSignal
from bec_lib.logger import bec_logger
from pydantic import Field, field_validator
from qtpy.QtCore import QSize, QStringListModel, Signal, Slot
from qtpy.QtCore import QSize, QStringListModel, Qt, Signal, Slot
from qtpy.QtWidgets import QComboBox, QCompleter, QSizePolicy
from bec_widgets.utils.bec_connector import ConnectionConfig
@@ -191,6 +191,13 @@ class DeviceComboBox(BECWidget, QComboBox):
if self.config.autocomplete:
self.autocomplete = True
self._callback_id = self.bec_dispatcher.client.callbacks.register(
EventType.DEVICE_UPDATE, self.on_device_update
)
self.device_config_update.connect(
self.update_devices_from_filters, Qt.ConnectionType.QueuedConnection
)
if available_devices is not None:
self.set_available_devices(available_devices)
@@ -216,10 +223,6 @@ class DeviceComboBox(BECWidget, QComboBox):
else:
self.setCurrentText("")
self._callback_id = self.bec_dispatcher.client.callbacks.register(
EventType.DEVICE_UPDATE, self.on_device_update
)
self.device_config_update.connect(self.update_devices_from_filters)
self.currentTextChanged.connect(self.check_validity)
self.check_validity(self.currentText())
@@ -255,6 +258,9 @@ class DeviceComboBox(BECWidget, QComboBox):
@SafeSlot()
def update_devices_from_filters(self):
"""Refresh the available device list from current device/readout/signal filters."""
if self._callback_id is None or getattr(self, "_destroyed", False):
return
self.config.device_filter = [entry.value for entry in self.device_filter]
self.config.readout_filter = [entry.value for entry in self.readout_filter]
self.config.signal_class_filter = self.signal_class_filter
@@ -497,9 +503,8 @@ class DeviceComboBox(BECWidget, QComboBox):
def cleanup(self):
"""Cleanup the widget."""
if self._callback_id is not None:
callback_id = self._callback_id
self.bec_dispatcher.client.callbacks.remove(self._callback_id)
self._callback_id = None
self.bec_dispatcher.client.callbacks.remove(callback_id)
super().cleanup()
def get_current_device(self) -> object:
@@ -603,10 +608,8 @@ class DeviceComboBox(BECWidget, QComboBox):
return device.readout_priority in self.readout_filter
def _update_validity_style(self, is_valid: bool) -> None:
if is_valid or not self.isEnabled():
self.setStyleSheet("")
return
self.setStyleSheet("QComboBox { border: 1px solid red; }")
border_color = "transparent" if is_valid or not self.isEnabled() else "red"
self.setStyleSheet(f"border: 1px solid {border_color};")
def _filter_devices_by_signal_class(
self, devices: list[Device | BECSignal | ComputedSignal | Positioner]
@@ -77,6 +77,7 @@ class SignalComboBox(BECWidget, QComboBox):
device_signal_changed = Signal(str)
signal_reset = Signal()
device_config_update = Signal()
def __init__(
self,
@@ -138,7 +139,10 @@ class SignalComboBox(BECWidget, QComboBox):
self.autocomplete = True
self._device_update_register = self.bec_dispatcher.client.callbacks.register(
EventType.DEVICE_UPDATE, self.update_signals_from_filters
EventType.DEVICE_UPDATE, self.on_device_update
)
self.device_config_update.connect(
self.update_signals_from_filters, Qt.ConnectionType.QueuedConnection
)
self.currentTextChanged.connect(self.on_text_changed)
@@ -197,21 +201,19 @@ class SignalComboBox(BECWidget, QComboBox):
self.update_signals_from_filters()
@SafeSlot()
@SafeSlot(str, dict)
def update_signals_from_filters(self, action: str | None = None, content: dict | None = None):
@SafeSlot(dict, dict)
def update_signals_from_filters(
self, content: dict | None = None, metadata: dict | None = None
):
"""Refresh available signals from the current device and filters.
Args:
action: Optional BEC device update action. If provided, only device list changing
actions trigger a refresh.
content: Optional callback payload from BEC device updates. Currently unused.
metadata: Optional callback metadata from BEC device updates. Currently unused.
"""
if self._device_update_register is None or getattr(self, "_destroyed", False):
return
if action is not None and action not in ["add", "remove", "reload"]:
return
self.config.signal_filter = [kind.name for kind in self.signal_filter]
if self._signal_class_filter:
@@ -252,6 +254,13 @@ class SignalComboBox(BECWidget, QComboBox):
),
)
def on_device_update(self, action: str, content: dict) -> None:
"""Refresh filters when BEC reports device configuration changes."""
if self._device_update_register is None or getattr(self, "_destroyed", False):
return
if action in ["add", "remove", "reload"]:
self.device_config_update.emit()
@Property(str)
def device(self) -> str:
"""Selected device."""
@@ -594,9 +603,8 @@ class SignalComboBox(BECWidget, QComboBox):
def cleanup(self):
"""Cleanup the widget."""
if self._device_update_register is not None:
callback_id = self._device_update_register
self.bec_dispatcher.client.callbacks.remove(self._device_update_register)
self._device_update_register = None
self.bec_dispatcher.client.callbacks.remove(callback_id)
super().cleanup()
@staticmethod
@@ -626,10 +634,8 @@ class SignalComboBox(BECWidget, QComboBox):
self.check_validity(self.currentText())
def _update_validity_style(self, is_valid: bool) -> None:
if is_valid or not self.isEnabled():
self.setStyleSheet("")
return
self.setStyleSheet("QComboBox { border: 1px solid red; }")
border_color = "transparent" if is_valid or not self.isEnabled() else "red"
self.setStyleSheet(f"border: 1px solid {border_color};")
def _replace_signal_items(self, items: list[str | tuple[str, dict]] | None = None):
combo_items = self._signals if items is None else items
@@ -20,12 +20,6 @@ from qtpy.QtWidgets import (
QVBoxLayout,
)
from bec_widgets.utils.scan_arg_metadata import (
apply_numeric_limits,
apply_numeric_precision,
apply_unit_metadata,
device_units,
)
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
BECDeviceFilter,
@@ -198,7 +192,7 @@ class ScanGroupBox(QGroupBox):
vbox_layout = QVBoxLayout(self)
hbox_layout = QHBoxLayout()
vbox_layout.addLayout(hbox_layout)
self.layout = QGridLayout()
self.layout = QGridLayout(self)
vbox_layout.addLayout(self.layout)
# Add bundle button
@@ -291,8 +285,8 @@ class ScanGroupBox(QGroupBox):
)
else:
widget = widget_class(parent=self.parent(), arg_name=arg_name, default=default)
apply_numeric_precision(widget, item)
apply_numeric_limits(widget, item)
self._apply_numeric_precision(widget, item)
self._apply_numeric_limits(widget, item)
if isinstance(widget, DeviceComboBox):
self.selected_devices[widget] = ""
widget.device_selected.connect(self.emit_device_selected)
@@ -304,7 +298,7 @@ class ScanGroupBox(QGroupBox):
if isinstance(widget, ScanLiteralsComboBox):
widget.set_literals(item["type"].get("Literal", []))
self._widget_configs[widget] = item
apply_unit_metadata(widget, item)
self._apply_unit_metadata(widget, item)
self.layout.addWidget(widget, row, column_index)
self.widgets.append(widget)
@@ -313,7 +307,7 @@ class ScanGroupBox(QGroupBox):
sender = self.sender()
self.selected_devices[sender] = device_name.strip()
if isinstance(sender, DeviceComboBox):
units = device_units(sender.get_current_device())
units = self._device_units(sender.get_current_device())
self._update_reference_units(sender, units)
self._emit_reference_units_changed(sender, units)
selected_devices_str = " ".join(self.selected_devices.values())
@@ -459,11 +453,57 @@ class ScanGroupBox(QGroupBox):
WidgetIO.set_value(widget, value)
break
@staticmethod
def _unit_tooltip(item: dict, units: str | None = None) -> str | None:
tooltip = item.get("tooltip", None)
reference_units = item.get("reference_units", None)
units = units or item.get("units", None)
tooltip_parts = [tooltip] if tooltip else []
if units:
tooltip_parts.append(f"Units: {units}")
elif reference_units:
tooltip_parts.append(f"Units from: {reference_units}")
if tooltip_parts:
return "\n".join(tooltip_parts)
return None
def _apply_unit_metadata(self, widget, item: dict, units: str | None = None) -> None:
units = units or item.get("units", None)
tooltip = self._unit_tooltip(item, units)
existing_tooltip = widget.toolTip()
if existing_tooltip:
# strip the existing unit info from the tooltip if it exists
# to avoid tooltip bloat on multiple updates
existing_tooltip = "\n".join(
line
for line in existing_tooltip.splitlines()
if not (line.startswith("Units:") or line.startswith("Units from:"))
).strip()
if tooltip:
if existing_tooltip:
widget.setToolTip(f"{existing_tooltip}\n{tooltip}")
else:
widget.setToolTip(tooltip)
if hasattr(widget, "setSuffix"):
widget.setSuffix(f" {units}" if units else "")
def _refresh_column_label(self, column: int, item: dict) -> None:
if column not in self._column_labels:
return
self._column_labels[column].setText(item.get("display_name", item.get("name", None)))
@staticmethod
def _device_units(device) -> str | None:
egu = getattr(device, "egu", None)
if not callable(egu):
return None
try:
return egu()
except Exception:
logger.exception("Failed to fetch engineering units from device %s", device)
return None
def _widget_position(self, widget) -> tuple[int, int] | None:
for row in range(self.layout.rowCount()):
for column in range(self.layout.columnCount()):
@@ -489,7 +529,7 @@ class ScanGroupBox(QGroupBox):
row, column = widget_position
if self.box_type == "args" and row != source_row:
continue
apply_unit_metadata(widget, item, units)
self._apply_unit_metadata(widget, item, units)
self._refresh_column_label(column, item)
def apply_reference_units(self, reference_name: str, units: str | None) -> None:
@@ -503,7 +543,7 @@ class ScanGroupBox(QGroupBox):
item = self._widget_configs.get(widget, {})
if item.get("reference_units") != reference_name:
continue
apply_unit_metadata(widget, item, units)
self._apply_unit_metadata(widget, item, units)
position = self._widget_position(widget)
if position is not None:
_, column = position
@@ -522,3 +562,49 @@ class ScanGroupBox(QGroupBox):
self.selected_devices[device_widget] = ""
self._update_reference_units(device_widget, None)
self._emit_reference_units_changed(device_widget, None)
@staticmethod
def _apply_numeric_precision(widget: ScanDoubleSpinBox, item: dict) -> None:
if not isinstance(widget, ScanDoubleSpinBox):
return
precision = item.get("precision")
if precision is None:
return
try:
widget.setDecimals(max(0, int(precision)))
except (TypeError, ValueError):
logger.warning(
"Ignoring invalid precision %r for parameter %s", precision, item.get("name")
)
@staticmethod
def _apply_numeric_limits(widget: ScanDoubleSpinBox | ScanSpinBox, item: dict) -> None:
if isinstance(widget, ScanSpinBox):
minimum = -2147483647 # largest int which qt allows
maximum = 2147483647
if item.get("ge") is not None:
minimum = int(item["ge"])
if item.get("gt") is not None:
minimum = int(item["gt"]) + 1
if item.get("le") is not None:
maximum = int(item["le"])
if item.get("lt") is not None:
maximum = int(item["lt"]) - 1
widget.setRange(minimum, maximum)
return
if isinstance(widget, ScanDoubleSpinBox):
minimum = -float("inf")
maximum = float("inf")
step = 10 ** (-widget.decimals())
if item.get("ge") is not None:
minimum = float(item["ge"])
if item.get("gt") is not None:
minimum = float(item["gt"]) + step
if item.get("le") is not None:
maximum = float(item["le"])
if item.get("lt") is not None:
maximum = float(item["lt"]) - step
widget.setRange(minimum, maximum)
@@ -2,12 +2,9 @@
from __future__ import annotations
import re
from typing import Any
from bec_widgets.utils.scan_arg_metadata import format_display_name as format_scan_display_name
from bec_widgets.utils.scan_arg_metadata import resolve_tooltip as resolve_scan_tooltip
from bec_widgets.utils.scan_arg_metadata import ui_config_from_metadata
AnnotationValue = str | dict[str, Any] | list[Any] | None
ScanArgumentMetadata = dict[str, Any]
SignatureEntry = dict[str, Any]
@@ -77,7 +74,8 @@ class ScanInfoAdapter:
Returns:
str: Formatted display label such as ``Exp Time``.
"""
return format_scan_display_name(name)
parts = re.split(r"(_|\d+)", name)
return " ".join(part.capitalize() for part in parts if part.isalnum()).strip()
@staticmethod
def resolve_tooltip(scan_argument: ScanArgumentMetadata) -> str | None:
@@ -89,7 +87,7 @@ class ScanInfoAdapter:
Returns:
str | None: Explicit tooltip text if provided, otherwise the description fallback.
"""
return resolve_scan_tooltip(scan_argument)
return scan_argument.get("tooltip") or scan_argument.get("description")
@staticmethod
def parse_annotation(
@@ -206,13 +204,24 @@ class ScanInfoAdapter:
Returns:
ScanInputConfig: Normalized input configuration.
"""
return ui_config_from_metadata(
name=name,
metadata=scan_argument,
input_type=self.scan_arg_type_from_annotation(annotation),
default=default,
arg=arg,
)
return {
"arg": arg,
"name": name,
"type": self.scan_arg_type_from_annotation(annotation),
"display_name": scan_argument.get("display_name") or self.format_display_name(name),
"tooltip": self.resolve_tooltip(scan_argument),
"default": default,
"expert": scan_argument.get("expert", False),
"hidden": scan_argument.get("hidden", False),
"precision": scan_argument.get("precision"),
"units": scan_argument.get("units"),
"reference_units": scan_argument.get("reference_units"),
"gt": scan_argument.get("gt"),
"ge": scan_argument.get("ge"),
"lt": scan_argument.get("lt"),
"le": scan_argument.get("le"),
"alternative_group": scan_argument.get("alternative_group"),
}
def build_scan_ui_config(self, scan_info: ScanInfo) -> ScanUIConfig:
"""Normalize one available-scan entry into the widget UI configuration.
@@ -460,7 +460,7 @@ class ImageBase(PlotBase):
self._color_bar = None
def disable_autorange():
logger.info("Disabling autorange")
print("Disabling autorange")
self.setProperty("autorange", False)
if style == "simple":
@@ -928,7 +928,7 @@ class ImageBase(PlotBase):
# if sync:
self._sync_colorbar_levels()
self._sync_autorange_switch()
logger.info(f"Autorange set to {enabled}")
print(f"Autorange set to {enabled}")
@SafeProperty(str)
def autorange_mode(self) -> str:
@@ -2449,7 +2449,7 @@ class Waveform(PlotBase):
first_key = next(iter(info))
mem_bytes = info[first_key]["value"]["mem_size"]
size_mb = mem_bytes / (1024 * 1024)
logger.info(f"Dataset size: {size_mb:.1f} MB")
print(f"Dataset size: {size_mb:.1f} MB")
except Exception as exc: # noqa: BLE001
logger.error(f"Unable to evaluate dataset size: {exc}")
return True
@@ -2,41 +2,50 @@ import sys
from enum import Enum
from string import Template
from qtpy.QtCore import QTimer
from qtpy.QtGui import QPalette
from qtpy.QtWidgets import QApplication, QProgressBar, QSizePolicy, QVBoxLayout, QWidget
from qtpy.QtCore import QEasingCurve, QPropertyAnimation, QRectF, Qt, QTimer
from qtpy.QtGui import QColor, QPainter, QPainterPath
class ProgressState(Enum):
NORMAL = "normal"
PAUSED = "paused"
INTERRUPTED = "interrupted"
COMPLETED = "completed"
@classmethod
def from_bec_status(cls, status: str) -> "ProgressState":
"""
Map a BEC status string (open, paused, aborted, halted, closed)
to the corresponding ProgressState.
Any unknown status falls back to NORMAL.
"""
mapping = {
"open": cls.NORMAL,
"paused": cls.PAUSED,
"aborted": cls.INTERRUPTED,
"halted": cls.PAUSED,
"closed": cls.COMPLETED,
}
return mapping.get(status.lower(), cls.NORMAL)
PROGRESS_STATE_COLORS = {
ProgressState.NORMAL: QColor("#2979ff"), # blue normal progress
ProgressState.PAUSED: QColor("#ffca28"), # orange/amber paused
ProgressState.INTERRUPTED: QColor("#ff5252"), # red interrupted
ProgressState.COMPLETED: QColor("#00e676"), # green finished
}
from qtpy.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
class ProgressState(Enum):
NORMAL = "normal"
PAUSED = "paused"
WARNING = "warning"
INTERRUPTED = "interrupted"
COMPLETED = "completed"
class BECProgressBar(BECWidget, QWidget):
"""
A BEC progress bar backed by Qt's native QProgressBar.
The displayed text can be customized using a template with $value, $maximum,
and $percentage placeholders.
Args:
parent: Parent Qt widget.
client: Optional BEC client instance.
config: Optional widget configuration.
gui_id: Optional GUI identifier used by the BEC widget infrastructure.
enable_dynamic_stylesheet: If True, adjust the chunk border radius while the
filled chunk is still too narrow for the target radius. This avoids Qt
stylesheet over-rounding artifacts on small progress values. Once the
target radius is usable, normal value updates no longer rebuild the
stylesheet.
**kwargs: Additional keyword arguments forwarded to BECWidget.
A custom progress bar with smooth transitions. The displayed text can be customized using a template.
"""
PLUGIN = True
@@ -52,15 +61,7 @@ class BECProgressBar(BECWidget, QWidget):
]
ICON_NAME = "page_control"
def __init__(
self,
parent=None,
client=None,
config=None,
gui_id=None,
enable_dynamic_stylesheet: bool = True,
**kwargs,
):
def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs):
super().__init__(
parent=parent, client=client, gui_id=gui_id, config=config, theme_update=True, **kwargs
)
@@ -70,6 +71,7 @@ class BECProgressBar(BECWidget, QWidget):
# internal values
self._oversampling_factor = 50
self._value = 0
self._target_value = 0
self._maximum = 100 * self._oversampling_factor
# User values
@@ -78,38 +80,46 @@ class BECProgressBar(BECWidget, QWidget):
self._user_maximum = 100
self._label_template = "$value / $maximum - $percentage %"
self._corner_radius = 8
# Color settings
self._background_color = QColor(30, 30, 30)
self._progress_color = accent_colors.highlight
self._completed_color = accent_colors.success
self._border_color = QColor(50, 50, 50)
# Cornerrounding: base radius in pixels (autoreduced if bar is small)
self._corner_radius = 10
# Progressbar state handling
self._state = ProgressState.NORMAL
self._state_colors = {
ProgressState.NORMAL: accent_colors.default,
ProgressState.PAUSED: accent_colors.highlight,
ProgressState.WARNING: accent_colors.warning,
ProgressState.PAUSED: accent_colors.warning,
ProgressState.INTERRUPTED: accent_colors.emergency,
ProgressState.COMPLETED: accent_colors.success,
}
# layout settings
self._padding_left_right = 10
self._chunk_radius = None
self._enable_dynamic_stylesheet = enable_dynamic_stylesheet
self._value_animation = QPropertyAnimation(self, b"_progressbar_value")
self._value_animation.setDuration(200)
self._value_animation.setEasingCurve(QEasingCurve.Type.OutCubic)
self.progressbar = QProgressBar(self)
self.progressbar.setTextVisible(True)
self.progressbar.setRange(0, self._maximum)
self.progressbar.setMinimumHeight(0)
self.progressbar.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Ignored)
# label on top of the progress bar
self.center_label = QLabel(self)
self.center_label.setAlignment(Qt.AlignHCenter)
self.center_label.setMinimumSize(0, 0)
self.center_label.setStyleSheet("background: transparent; color: white;")
self._layout = QVBoxLayout(self)
self._layout.setContentsMargins(self._padding_left_right, 0, self._padding_left_right, 0)
self._layout.setSpacing(0)
self._layout.addWidget(self.progressbar)
self.setLayout(self._layout)
layout = QVBoxLayout(self)
layout.setContentsMargins(10, 0, 10, 0)
layout.setSpacing(0)
layout.addWidget(self.center_label)
layout.setAlignment(self.center_label, Qt.AlignCenter)
self.setLayout(layout)
self._sync_progressbar()
self._apply_state_style()
self.update()
self._adjust_label_width()
@SafeProperty(
str, doc="The template for the center label. Use $value, $maximum, and $percentage."
@@ -130,18 +140,17 @@ class BECProgressBar(BECWidget, QWidget):
accent_colors = get_accent_colors()
self._state_colors = {
ProgressState.NORMAL: accent_colors.default,
ProgressState.PAUSED: accent_colors.highlight,
ProgressState.WARNING: accent_colors.warning,
ProgressState.PAUSED: accent_colors.warning,
ProgressState.INTERRUPTED: accent_colors.emergency,
ProgressState.COMPLETED: accent_colors.success,
}
self._chunk_radius = None
self._apply_state_style()
@label_template.setter
def label_template(self, template):
self._label_template = template
self._sync_progressbar()
self._adjust_label_width()
self.set_value(self._user_value)
self.update()
@SafeProperty(float, designable=False)
def _progressbar_value(self):
@@ -153,16 +162,28 @@ class BECProgressBar(BECWidget, QWidget):
@_progressbar_value.setter
def _progressbar_value(self, val):
self._value = val
self.progressbar.setValue(int(round(val)))
self.update()
def _update_template(self):
template = Template(self._label_template)
return template.safe_substitute(
value=self._user_value,
maximum=self._user_maximum,
percentage=int(self._percentage(self._user_value)),
percentage=int((self.map_value(self._user_value) / self._maximum) * 100),
)
def _adjust_label_width(self):
"""
Reserve enough horizontal space for the center label so the widget
doesn't resize as the text grows during progress.
"""
template = Template(self._label_template)
sample_text = template.safe_substitute(
value=self._user_maximum, maximum=self._user_maximum, percentage=100
)
width = self.center_label.fontMetrics().horizontalAdvance(sample_text)
self.center_label.setFixedWidth(width)
@SafeSlot(float)
@SafeSlot(int)
def set_value(self, value):
@@ -172,35 +193,21 @@ class BECProgressBar(BECWidget, QWidget):
Args:
value (float): The value to set.
"""
previous_visual_state = self._current_visual_state()
previous_value = self._value
self._user_value = self._clamp_value(value)
self._value = self.map_value(self._user_value)
if self._enable_dynamic_stylesheet and self._value < previous_value:
self._chunk_radius = None
if value > self._user_maximum:
value = self._user_maximum
elif value < self._user_minimum:
value = self._user_minimum
self._target_value = self.map_value(value)
self._user_value = value
self.center_label.setText(self._update_template())
# Update state automatically unless paused or interrupted
if self._state not in (
ProgressState.PAUSED,
ProgressState.WARNING,
ProgressState.INTERRUPTED,
):
if self._state not in (ProgressState.PAUSED, ProgressState.INTERRUPTED):
self._state = (
ProgressState.COMPLETED
if self._user_value >= self._user_maximum
else ProgressState.NORMAL
)
self._sync_progressbar()
visual_state_changed = self._current_visual_state() is not previous_visual_state
if visual_state_changed:
self._chunk_radius = None
if (
self._enable_dynamic_stylesheet
and not visual_state_changed
and (self._chunk_radius is None or self._chunk_radius != self._target_chunk_radius())
):
self._update_chunk_radius()
if visual_state_changed:
self._apply_state_style()
self.animate_progress()
@SafeProperty(object, doc="Current visual state of the progress bar.")
def state(self):
@@ -219,8 +226,7 @@ class BECProgressBar(BECWidget, QWidget):
if not isinstance(state, ProgressState):
raise ValueError("state must be a ProgressState or its value")
self._state = state
self._chunk_radius = None
self._apply_state_style()
self.update()
@SafeProperty(float, doc="Base corner radius in pixels (autoscaled down on small bars).")
def corner_radius(self) -> float:
@@ -229,18 +235,7 @@ class BECProgressBar(BECWidget, QWidget):
@corner_radius.setter
def corner_radius(self, radius: float):
self._corner_radius = max(0.0, radius)
self._chunk_radius = None
self._apply_state_style()
@SafeProperty(bool)
def enable_dynamic_stylesheet(self) -> bool:
return self._enable_dynamic_stylesheet
@enable_dynamic_stylesheet.setter
def enable_dynamic_stylesheet(self, enabled: bool):
self._enable_dynamic_stylesheet = bool(enabled)
self._chunk_radius = None
self._apply_state_style()
self.update()
@SafeProperty(float)
def padding_left_right(self) -> float:
@@ -249,12 +244,60 @@ class BECProgressBar(BECWidget, QWidget):
@padding_left_right.setter
def padding_left_right(self, padding: float):
self._padding_left_right = padding
self._layout.setContentsMargins(int(round(padding)), 0, int(round(padding)), 0)
self.update()
def resizeEvent(self, event):
super().resizeEvent(event)
self._chunk_radius = None
self._update_chunk_radius()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
rect = self.rect().adjusted(self._padding_left_right, 0, -self._padding_left_right, -1)
# Corner radius adapts to widget height so it never exceeds half the bars thickness
radius = min(self._corner_radius, rect.height() / 2)
# Draw background
painter.setBrush(self._background_color)
painter.setPen(Qt.NoPen)
painter.drawRoundedRect(rect, radius, radius) # Rounded corners
# Draw border
painter.setBrush(Qt.NoBrush)
painter.setPen(self._border_color)
painter.drawRoundedRect(rect, radius, radius)
# Determine progress colour based on current state
if self._state == ProgressState.PAUSED:
current_color = self._state_colors[ProgressState.PAUSED]
elif self._state == ProgressState.INTERRUPTED:
current_color = self._state_colors[ProgressState.INTERRUPTED]
elif self._state == ProgressState.COMPLETED or self._value >= self._maximum:
current_color = self._state_colors[ProgressState.COMPLETED]
else:
current_color = self._state_colors[ProgressState.NORMAL]
# Set clipping region to preserve the background's rounded corners
progress_rect = rect.adjusted(
0, 0, int(-rect.width() + (self._value / self._maximum) * rect.width()), 0
)
clip_path = QPainterPath()
clip_path.addRoundedRect(
QRectF(rect), radius, radius
) # Clip to the background's rounded corners
painter.setClipPath(clip_path)
# Draw progress bar
painter.setBrush(current_color)
painter.drawRect(progress_rect) # Less rounded, no additional rounding
painter.end()
def animate_progress(self):
"""
Animate the progress bar from the current value to the target value.
"""
self._value_animation.stop()
self._value_animation.setStartValue(self._value)
self._value_animation.setEndValue(self._target_value)
self._value_animation.start()
@SafeProperty(float)
def maximum(self):
@@ -300,11 +343,10 @@ class BECProgressBar(BECWidget, QWidget):
Args:
maximum (float): The maximum value.
"""
previous_maximum = self._user_maximum
self._user_maximum = maximum
if self._enable_dynamic_stylesheet and maximum != previous_maximum:
self._chunk_radius = None
self._adjust_label_width()
self.set_value(self._user_value) # Update the value to fit the new range
self.update()
@SafeSlot(float)
def set_minimum(self, minimum: float):
@@ -314,126 +356,40 @@ class BECProgressBar(BECWidget, QWidget):
Args:
minimum (float): The minimum value.
"""
previous_minimum = self._user_minimum
self._user_minimum = minimum
if self._enable_dynamic_stylesheet and minimum != previous_minimum:
self._chunk_radius = None
self.set_value(self._user_value) # Update the value to fit the new range
self.update()
def map_value(self, value: float):
"""
Map the user value to the range [0, 100*self._oversampling_factor] for the progress
"""
span = self._user_maximum - self._user_minimum
if span <= 0:
return float(self._maximum if value >= self._user_maximum else 0)
mapped_value = (value - self._user_minimum) / span * self._maximum
return min(float(self._maximum), max(0.0, mapped_value))
def _percentage(self, value: float) -> float:
return (self.map_value(value) / self._maximum) * 100 if self._maximum else 0.0
def _clamp_value(self, value: float) -> float:
if self._user_maximum <= self._user_minimum:
return self._user_maximum
return min(self._user_maximum, max(self._user_minimum, value))
def _sync_progressbar(self) -> None:
self.progressbar.setRange(0, int(self._maximum))
self.progressbar.setValue(int(round(self._value)))
self.progressbar.setFormat(self._update_template())
def _setup_style_sheet(self, *, chunk_radius: int) -> None:
radius = int(round(self._corner_radius))
chunk_color = self._state_colors[self._current_visual_state()].name()
self.progressbar.setStyleSheet(f"""
QProgressBar {{
background-color: palette(mid);
border: none;
border-radius: {radius}px;
color: palette(text);
text-align: center;
}}
QProgressBar::chunk {{
background-color: {chunk_color};
border-radius: {chunk_radius}px;
}}
""")
def _update_chunk_radius(self) -> None:
chunk_radius = self._current_chunk_radius()
if chunk_radius != self._chunk_radius:
self._chunk_radius = chunk_radius
self._setup_style_sheet(chunk_radius=chunk_radius)
self._apply_state_palette()
def _apply_state_style(self) -> None:
if self._chunk_radius is None:
self._chunk_radius = self._current_chunk_radius()
self._setup_style_sheet(chunk_radius=self._chunk_radius)
self._apply_state_palette()
def _apply_state_palette(self) -> None:
color = self._state_colors[self._current_visual_state()]
palette = self.progressbar.palette()
palette.setColor(QPalette.ColorRole.Highlight, color)
palette.setColor(QPalette.ColorRole.HighlightedText, palette.color(QPalette.ColorRole.Text))
self.progressbar.setPalette(palette)
def _current_chunk_radius(self) -> int:
target_radius = self._target_chunk_radius()
if not self._enable_dynamic_stylesheet:
return target_radius
return self._calculate_chunk_radius(target_radius)
def _target_chunk_radius(self) -> int:
radius = int(round(self._corner_radius))
return max(0, radius - 1)
def _calculate_chunk_radius(self, target_radius: int) -> int:
"""
Scale the chunk radius down while the filled part is narrower than the target radius.
Qt stylesheets otherwise over-round very small chunks.
"""
if target_radius <= 0 or self._maximum <= 0:
return 0
fill_width = self.progressbar.width() * min(1.0, max(0.0, self._value / self._maximum))
if fill_width <= 0:
return 0
return min(target_radius, max(1, int(fill_width / 2)))
def _current_visual_state(self) -> ProgressState:
if self._state in (ProgressState.PAUSED, ProgressState.WARNING, ProgressState.INTERRUPTED):
return self._state
if self._state == ProgressState.COMPLETED or self._value >= self._maximum:
return ProgressState.COMPLETED
return ProgressState.NORMAL
return (
(value - self._user_minimum) / (self._user_maximum - self._user_minimum) * self._maximum
)
def _get_label(self) -> str:
"""Return the label text. mostly used for testing rpc."""
return self.progressbar.text()
return self.center_label.text()
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
progress_bar = BECProgressBar()
progress_bar.setWindowTitle("BEC Progress Bar")
progress_bar.resize(360, 48)
progress_bar.set_minimum(-100)
progress_bar.set_maximum(0)
progress_bar.set_value(-100)
progress_bar.show()
progressBar = BECProgressBar()
progressBar.show()
progressBar.set_minimum(-100)
progressBar.set_maximum(0)
# Example of setting values
def update_progress():
value = progress_bar._user_value + 2.5
if value > progress_bar._user_maximum:
value = progress_bar._user_minimum
progress_bar.set_value(value)
value = progressBar._user_value + 2.5
if value > progressBar._user_maximum:
value = -100 # progressBar._maximum / progressBar._upsampling_factor
progressBar.set_value(value)
timer = QTimer(progress_bar)
timer = QTimer()
timer.timeout.connect(update_progress)
timer.start(200)
timer.start(200) # Update every half second
sys.exit(app.exec())
@@ -1,288 +0,0 @@
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Literal
import numpy as np
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import QObject, QTimer, Signal
from bec_widgets.utils.error_popups import SafeSlot
@dataclass(frozen=True)
class ProgressSnapshot:
value: float
max_value: float
done: bool
status: Literal["open", "paused", "aborted", "halted", "closed", "user_completed"]
scan_id: str | None = None
scan_number: int | None = None
rid: str | None = None
is_new_scan: bool = False
class ProgressTask(QObject):
"""
Class to store progress information.
Inspired by https://github.com/Textualize/rich/blob/master/rich/progress.py
"""
def __init__(
self, parent: QObject | None, value: float = 0, max_value: float = 0, done: bool = False
):
super().__init__(parent=parent)
self.start_time = time.monotonic()
self.done = done
self.value = value
self.max_value = max_value
self._elapsed_time = 0
self.timer = QTimer(self)
self.timer.timeout.connect(self.update_elapsed_time)
self.timer.start(1000)
def update(self, value: float, max_value: float, done: bool = False):
"""
Update the progress.
"""
self.max_value = max_value
self.done = done
self.value = value
if done:
self.timer.stop()
def update_elapsed_time(self):
"""
Update the time estimates. This is called every second by a QTimer.
"""
self._elapsed_time = max(0.0, time.monotonic() - self.start_time)
@property
def percentage(self) -> float:
"""float: Get progress of task as a percentage. If a None total was set, returns 0"""
if not self.max_value:
return 0.0
completed = (self.value / self.max_value) * 100.0
completed = min(100.0, max(0.0, completed))
return completed
@property
def speed(self) -> float:
"""Get the estimated speed in steps per second."""
if self._elapsed_time == 0:
return 0.0
return self.value / self._elapsed_time
@property
def frequency(self) -> float:
"""Get the estimated frequency in steps per second."""
if self.speed == 0:
return 0.0
return 1 / self.speed
@property
def time_elapsed(self) -> str:
return self._format_time(int(self._elapsed_time))
@property
def remaining(self) -> float:
"""Get the estimated remaining steps."""
if self.done:
return 0.0
remaining = self.max_value - self.value
return remaining
@property
def time_remaining(self) -> str:
"""
Get the estimated remaining time in the format HH:MM:SS.
"""
if self.done or not self.speed or not self.remaining:
return self._format_time(0)
estimate = int(np.round(self.remaining / self.speed))
return self._format_time(estimate)
@staticmethod
def _format_time(seconds: float) -> str:
"""
Format the time in seconds to a string in the format HH:MM:SS.
"""
return f"{seconds // 3600:02}:{(seconds // 60) % 60:02}:{seconds % 60:02}"
class BECProgressTracker(QObject):
"""
Shared backend for BEC scan progress messages.
"""
progress_started = Signal(object)
progress_updated = Signal(object)
progress_finished = Signal(object)
progress_cleared = Signal()
def __init__(self, bec_dispatcher, parent: QObject | None = None):
super().__init__(parent=parent)
self.bec_dispatcher = bec_dispatcher
self._connected = False
self.task: ProgressTask | None = None
self.scan_number: int | None = None
self._active_scan_id: str | None = None
self._active_rid: str | None = None
self._last_reset_scan_id: str | None = None
self._last_progress_scan_id: str | None = None
def start(self) -> None:
if self._connected:
return
self.bec_dispatcher.connect_slot(
self.process_progress_message, MessageEndpoints.scan_progress()
)
self.bec_dispatcher.connect_slot(
self.process_scan_status_message, MessageEndpoints.scan_status()
)
self._connected = True
def _start_task(self, scan_id: str | None, rid: str | None = None) -> None:
if self.task is not None:
self.task.timer.stop()
self.task.deleteLater()
self.task = ProgressTask(parent=self)
self._active_scan_id = scan_id
self._active_rid = rid
self.progress_started.emit(
ProgressSnapshot(
value=0,
max_value=100,
done=False,
status="open",
scan_id=self._active_scan_id,
scan_number=self.scan_number,
rid=self._active_rid,
)
)
def clear_task(self, *, emit_finished: bool = True) -> None:
if self.task is None:
self._active_scan_id = None
self._active_rid = None
self.progress_cleared.emit()
return
self.task.timer.stop()
self.task.deleteLater()
self.task = None
self._active_scan_id = None
self._active_rid = None
self.progress_cleared.emit()
if emit_finished:
self.progress_finished.emit(
ProgressSnapshot(
value=0,
max_value=100,
done=True,
status="open",
scan_id=self._active_scan_id,
scan_number=self.scan_number,
rid=self._active_rid,
)
)
@SafeSlot(dict, dict)
def process_progress_message(
self, msg_content: dict, metadata: dict
) -> ProgressSnapshot | None:
done = msg_content.get("done", False)
value = msg_content.get("value", 0)
max_value = msg_content.get("max_value", 100)
status: Literal["open", "paused", "aborted", "halted", "closed", "user_completed"] = (
metadata.get("status", "open")
)
scan_id = metadata.get("scan_id") or metadata.get("RID")
rid = metadata.get("RID")
scan_number = metadata.get("scan_number")
if scan_number is not None:
self.scan_number = scan_number
if scan_id is not None:
self._last_progress_scan_id = scan_id
is_new_scan = False
previous_scan_id = self._active_scan_id
previous_rid = self._active_rid
identity_changed = (
(scan_id is not None and scan_id != previous_scan_id)
or (rid is not None and rid != previous_rid)
or (previous_scan_id is None and previous_rid is None)
)
if self.task is None:
self._start_task(scan_id, rid=rid)
is_new_scan = identity_changed
elif scan_id is not None and scan_id != self._active_scan_id:
self._start_task(scan_id, rid=rid)
is_new_scan = True
elif rid is not None and rid != self._active_rid:
self._start_task(scan_id or self._active_scan_id, rid=rid)
is_new_scan = True
if self.task is None:
return None
self.task.update(value, max_value, done)
snapshot = ProgressSnapshot(
value=value,
max_value=max_value,
done=done,
status=status,
scan_id=self._active_scan_id,
scan_number=self.scan_number,
rid=self._active_rid,
is_new_scan=is_new_scan,
)
self.progress_updated.emit(snapshot)
if done:
self.clear_task()
return snapshot
@SafeSlot(dict, dict)
def process_scan_status_message(
self, msg_content: dict, metadata: dict
) -> ProgressSnapshot | None:
if msg_content.get("status") != "open":
return None
scan_id = msg_content.get("scan_id") or metadata.get("scan_id") or metadata.get("RID")
if scan_id is None or scan_id == self._last_reset_scan_id:
return None
if scan_id == self._last_progress_scan_id:
self._last_reset_scan_id = scan_id
return None
self.clear_task(emit_finished=False)
self._last_reset_scan_id = scan_id
self.scan_number = msg_content.get("scan_number")
snapshot = ProgressSnapshot(
value=0,
max_value=100,
done=False,
status="open",
scan_id=scan_id,
scan_number=self.scan_number,
rid=metadata.get("RID"),
is_new_scan=True,
)
self.progress_updated.emit(snapshot)
return snapshot
def cleanup(self) -> None:
self.clear_task(emit_finished=False)
if self._connected:
self.bec_dispatcher.disconnect_slot(
self.process_progress_message, MessageEndpoints.scan_progress()
)
self.bec_dispatcher.disconnect_slot(
self.process_scan_status_message, MessageEndpoints.scan_status()
)
self._connected = False
self._last_reset_scan_id = None
self._last_progress_scan_id = None
@@ -13,7 +13,6 @@ from bec_widgets import BECWidget
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.colors import Colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.widgets.progress.progress_backend import BECProgressTracker, ProgressSnapshot
logger = bec_logger.logger
if TYPE_CHECKING:
@@ -82,8 +81,6 @@ class Ring(BECWidget, QWidget):
self._color: QColor = self.convert_color(self.config.color)
self._background_color: QColor = self.convert_color(self.config.background_color)
self.registered_slot: tuple[Callable, str | EndpointInfo] | None = None
self.progress_tracker = BECProgressTracker(self.bec_dispatcher, parent=self)
self.progress_tracker.progress_updated.connect(self._on_progress_snapshot)
self.RID = None
self._gap = 5
self._hovered = False
@@ -222,32 +219,35 @@ class Ring(BECWidget, QWidget):
case "manual":
if self.config.mode == "manual":
return
self._disconnect_registered_update()
if self.registered_slot is not None:
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
self.config.mode = "manual"
self.registered_slot = None
case "scan":
if self.config.mode == "scan":
return
self._disconnect_registered_update()
if self.registered_slot is not None:
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
self.config.mode = "scan"
self.progress_tracker.start()
self.bec_dispatcher.connect_slot(
self.on_scan_progress, MessageEndpoints.scan_progress()
)
self.registered_slot = (self.on_scan_progress, MessageEndpoints.scan_progress())
case "device":
self._disconnect_registered_update()
if self.registered_slot is not None:
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
self.config.mode = "device"
if device == "":
self.registered_slot = None
return
self.config.device = device
# self.config.signal = self._get_signal_from_device(device, signal)
signal = self._update_device_connection(device, signal)
self.config.signal = signal
case _:
raise ValueError(f"Unsupported mode: {mode}")
def _disconnect_registered_update(self):
if self.registered_slot is not None:
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
self.registered_slot = None
self.progress_tracker.cleanup()
def set_precision(self, precision: int):
"""
Set the precision for the ring widget.
@@ -270,13 +270,13 @@ class Ring(BECWidget, QWidget):
def _get_signals_for_device(self, device: str) -> dict[str, list[str]]:
"""
Get the appropriate signals for the device to be used in the ring widget, based on the signal infos from the device manager.
Get the signals for the device.
Args:
device(str): Device name for the device readback mode
device(str): Device name for the device
Returns:
dict[str, list[str]]: Signal infos for the device to be used in the ring widget
dict[str, list[str]]: Dictionary with the signals for the device
"""
dm = self.bec_dispatcher.client.device_manager
if not dm:
@@ -285,25 +285,24 @@ class Ring(BECWidget, QWidget):
if dev_obj is None:
raise ValueError(f"Device '{device}' not found in device manager.")
signal_infos = getattr(dev_obj, "_info", {}).get("signals", {})
progress_signals = [
obj["component_name"]
for obj in signal_infos.values()
if obj.get("signal_class") == "ProgressSignal"
for obj in dev_obj._info["signals"].values()
if obj["signal_class"] == "ProgressSignal"
]
hinted_signals = [
obj["obj_name"]
for obj in signal_infos.values()
if obj.get("kind_str") == "hinted"
and obj.get("signal_class")
for obj in dev_obj._info["signals"].values()
if obj["kind_str"] == "hinted"
and obj["signal_class"]
not in ["ProgressSignal", "AsyncSignal", "AsyncMultiSignal", "DynamicSignal"]
]
normal_signals = [
obj["component_name"]
for obj in signal_infos.values()
if obj.get("kind_str") == "normal"
for obj in dev_obj._info["signals"].values()
if obj["kind_str"] == "normal"
]
return {
"progress_signals": progress_signals,
"hinted_signals": hinted_signals,
@@ -312,15 +311,21 @@ class Ring(BECWidget, QWidget):
def _update_device_connection(self, device: str, signal: str | None) -> str:
"""
Subscribe device mode to the endpoint matching the selected signal.
Update the device connection for the ring widget.
When no signal is provided, the ring selects the first available progress
signal, then the first hinted readback signal, then the first normal
readback signal. Progress signals use the device_progress endpoint;
readback signals use the device_readback endpoint.
In general, we support two modes here:
- If signal is provided, we use that directly.
- If signal is not provided, we try to get the signal from the device manager.
We first check for progress signals, then for hinted signals, and finally for normal signals.
Depending on what type of signal we get (progress or hinted/normal), we subscribe to different endpoints.
Args:
device(str): Device name for the device mode
signal(str): Signal name for the device mode
Returns:
The selected signal name, or an empty string if the device is not known.
str: The selected signal name for the device mode
"""
logger.info(f"Updating device connection for device '{device}' and signal '{signal}'")
dm = self.bec_dispatcher.client.device_manager
@@ -336,17 +341,18 @@ class Ring(BECWidget, QWidget):
normal_signals = signals["normal_signals"]
if not signal:
if progress_signals:
# If signal is not provided, we try to get it from the device manager
if len(progress_signals) > 0:
signal = progress_signals[0]
logger.info(
f"Using progress signal '{signal}' for device '{device}' in ring progress bar."
)
elif hinted_signals:
elif len(hinted_signals) > 0:
signal = hinted_signals[0]
logger.info(
f"Using hinted signal '{signal}' for device '{device}' in ring progress bar."
)
elif normal_signals:
elif len(normal_signals) > 0:
signal = normal_signals[0]
logger.info(
f"Using normal signal '{signal}' for device '{device}' in ring progress bar."
@@ -360,18 +366,26 @@ class Ring(BECWidget, QWidget):
self.bec_dispatcher.connect_slot(self.on_device_progress, endpoint)
self.registered_slot = (self.on_device_progress, endpoint)
return signal
if signal in hinted_signals or signal in normal_signals:
endpoint = MessageEndpoints.device_readback(device)
self.bec_dispatcher.connect_slot(self.on_device_readback, endpoint)
self.registered_slot = (self.on_device_readback, endpoint)
return signal
raise ValueError(
f"Signal '{signal}' is not usable for ring progress device mode. "
f"Available progress signals: {progress_signals}; "
f"available readback signals: {hinted_signals + normal_signals}."
)
@SafeSlot(dict, dict)
def on_scan_progress(self, msg, meta):
"""
Update the ring widget with the scan progress.
Args:
msg(dict): Message with the scan progress
meta(dict): Metadata for the message
"""
current_RID = meta.get("RID", None)
if current_RID != self.RID:
self.set_min_max_values(0, msg.get("max_value", 100))
self.set_value(msg.get("value", 0))
self.update()
@SafeSlot(dict, dict)
def on_device_readback(self, msg, meta):
@@ -394,31 +408,30 @@ class Ring(BECWidget, QWidget):
@SafeSlot(dict, dict)
def on_device_progress(self, msg, meta):
"""
Update the ring widget with the device progress.
Args:
msg(dict): Message with the device progress
meta(dict): Metadata for the message
"""
device = self.config.device
if device is None:
return
max_val = msg.get("max_value", 100)
self.set_min_max_values(0, max_val)
self.set_value(max_val if msg.get("done") else msg.get("value", 0))
self.update()
def _on_progress_snapshot(self, snapshot: ProgressSnapshot):
if snapshot.is_new_scan:
self.set_min_max_values(0, snapshot.max_value)
self.RID = snapshot.rid
self.set_value(snapshot.value)
value = msg.get("value", 0)
if msg.get("done"):
value = max_val
self.set_value(value)
self.update()
def paintEvent(self, event):
if not self.progress_container:
return
size = min(self.width(), self.height())
if size <= 0 or not self.isVisible():
return
painter = QtGui.QPainter(self)
if not painter.isActive():
return
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
size = min(self.width(), self.height())
# Center the ring
x_offset = (self.width() - size) // 2
@@ -496,6 +509,15 @@ class Ring(BECWidget, QWidget):
return QtGui.QColor(*color)
raise ValueError(f"Unsupported color format: {color}")
def cleanup(self):
"""
Cleanup the ring widget.
Disconnect any registered slots.
"""
if self.registered_slot is not None:
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
self.registered_slot = None
###############################################
####### QProperties ###########################
###############################################
@@ -644,7 +666,6 @@ class Ring(BECWidget, QWidget):
if self.registered_slot is not None:
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
self.registered_slot = None
self.progress_tracker.cleanup()
self._hover_animation.stop()
super().cleanup()
@@ -1,27 +1,121 @@
from __future__ import annotations
import enum
import os
import time
from typing import Literal
import numpy as np
from bec_lib import messages
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from qtpy.QtCore import Signal
from qtpy.QtCore import QObject, QTimer, Signal
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import ProgressState
from bec_widgets.widgets.progress.progress_backend import BECProgressTracker, ProgressSnapshot
logger = bec_logger.logger
BEC_STATUS_TO_PROGRESS_STATE = {
"open": ProgressState.NORMAL,
"paused": ProgressState.PAUSED,
"aborted": ProgressState.WARNING,
"halted": ProgressState.INTERRUPTED,
"closed": ProgressState.COMPLETED,
"user_completed": ProgressState.COMPLETED,
}
class ProgressSource(enum.Enum):
"""
Enum to define the source of the progress.
"""
SCAN_PROGRESS = "scan_progress"
DEVICE_PROGRESS = "device_progress"
class ProgressTask(QObject):
"""
Class to store progress information.
Inspired by https://github.com/Textualize/rich/blob/master/rich/progress.py
"""
def __init__(self, parent: QWidget, value: float = 0, max_value: float = 0, done: bool = False):
super().__init__(parent=parent)
self.start_time = time.time()
self.done = done
self.value = value
self.max_value = max_value
self._elapsed_time = 0
self.timer = QTimer(self)
self.timer.timeout.connect(self.update_elapsed_time)
self.timer.start(100) # update the elapsed time every 100 ms
def update(self, value: float, max_value: float, done: bool = False):
"""
Update the progress.
"""
self.max_value = max_value
self.done = done
self.value = value
if done:
self.timer.stop()
def update_elapsed_time(self):
"""
Update the time estimates. This is called every 100 ms by a QTimer.
"""
self._elapsed_time += 0.1
@property
def percentage(self) -> float:
"""float: Get progress of task as a percentage. If a None total was set, returns 0"""
if not self.max_value:
return 0.0
completed = (self.value / self.max_value) * 100.0
completed = min(100.0, max(0.0, completed))
return completed
@property
def speed(self) -> float:
"""Get the estimated speed in steps per second."""
if self._elapsed_time == 0:
return 0.0
return self.value / self._elapsed_time
@property
def frequency(self) -> float:
"""Get the estimated frequency in steps per second."""
if self.speed == 0:
return 0.0
return 1 / self.speed
@property
def time_elapsed(self) -> str:
# format the elapsed time to a string in the format HH:MM:SS
return self._format_time(int(self._elapsed_time))
@property
def remaining(self) -> float:
"""Get the estimated remaining steps."""
if self.done:
return 0.0
remaining = self.max_value - self.value
return remaining
@property
def time_remaining(self) -> str:
"""
Get the estimated remaining time in the format HH:MM:SS.
"""
if self.done or not self.speed or not self.remaining:
return self._format_time(0)
estimate = int(np.round(self.remaining / self.speed))
return self._format_time(estimate)
def _format_time(self, seconds: float) -> str:
"""
Format the time in seconds to a string in the format HH:MM:SS.
"""
return f"{seconds // 3600:02}:{(seconds // 60) % 60:02}:{seconds % 60:02}"
class ScanProgressBar(BECWidget, QWidget):
@@ -36,14 +130,7 @@ class ScanProgressBar(BECWidget, QWidget):
progress_finished = Signal()
def __init__(
self,
parent=None,
client=None,
config=None,
gui_id=None,
one_line_design=False,
enable_dynamic_stylesheet: bool = True,
**kwargs,
self, parent=None, client=None, config=None, gui_id=None, one_line_design=False, **kwargs
):
super().__init__(parent=parent, client=client, config=config, gui_id=gui_id, **kwargs)
@@ -59,43 +146,84 @@ class ScanProgressBar(BECWidget, QWidget):
self.layout.addWidget(self.ui)
self.setLayout(self.layout)
self.progressbar = self.ui.progressbar
self.progressbar.enable_dynamic_stylesheet = enable_dynamic_stylesheet
self._show_elapsed_time = self.ui.elapsed_time_label.isVisible()
self._show_remaining_time = self.ui.remaining_time_label.isVisible()
self._show_source_label = self.ui.source_label.isVisible()
self.progress_tracker = BECProgressTracker(self.bec_dispatcher, parent=self)
self.progress_tracker.progress_started.connect(self._on_progress_started)
self.progress_tracker.progress_updated.connect(self._on_progress_snapshot)
self.progress_tracker.progress_finished.connect(
lambda _snapshot: self.progress_finished.emit()
)
self.progress_tracker.start()
self.connect_to_queue()
self._progress_source = None
self._progress_device = None
self.task = None
self.scan_number = None
self.progress_started.connect(lambda: print("Scan progress started"))
def update_source_label(self):
scan_number = self.progress_tracker.scan_number
scan_text = f"Scan {scan_number}" if scan_number is not None else "Scan"
if self.ui.source_label.text() == scan_text:
def connect_to_queue(self):
"""
Connect to the queue status signal.
"""
self.bec_dispatcher.connect_slot(self.on_queue_update, MessageEndpoints.scan_queue_status())
def set_progress_source(self, source: ProgressSource, device=None):
"""
Set the source of the progress.
"""
if self._progress_source == source and self._progress_device == device:
self.update_source_label(source, device=device)
return
logger.info(f"Set progress source to {scan_text}")
self.ui.source_label.setText(scan_text)
def _on_progress_started(self, _snapshot: ProgressSnapshot):
if self.progress_tracker.task is not None:
self.progress_tracker.task.timer.timeout.connect(self.update_labels)
self.progress_started.emit()
def _on_progress_snapshot(self, snapshot: ProgressSnapshot):
self.update_labels()
if snapshot.is_new_scan and self.progress_tracker.task is None:
self.ui.elapsed_time_label.setText("00:00:00")
self.ui.remaining_time_label.setText("00:00:00")
self.update_source_label()
self.progressbar.set_maximum(snapshot.max_value)
self.progressbar.set_value(snapshot.value)
self.progressbar.state = BEC_STATUS_TO_PROGRESS_STATE.get(
snapshot.status.lower(), ProgressState.NORMAL
if self._progress_source is not None:
self.bec_dispatcher.disconnect_slot(
self.on_progress_update,
(
MessageEndpoints.scan_progress()
if self._progress_source == ProgressSource.SCAN_PROGRESS
else MessageEndpoints.device_progress(device=self._progress_device)
),
)
self._progress_source = source
self._progress_device = None if source == ProgressSource.SCAN_PROGRESS else device
self.bec_dispatcher.connect_slot(
self.on_progress_update,
(
MessageEndpoints.scan_progress()
if source == ProgressSource.SCAN_PROGRESS
else MessageEndpoints.device_progress(device=device)
),
)
self.update_source_label(source, device=device)
# self.progress_started.emit()
def update_source_label(self, source: ProgressSource, device=None):
scan_text = f"Scan {self.scan_number}" if self.scan_number is not None else "Scan"
text = scan_text if source == ProgressSource.SCAN_PROGRESS else f"Device {device}"
logger.info(f"Set progress source to {text}")
self.ui.source_label.setText(text)
@SafeSlot(dict, dict)
def on_progress_update(self, msg_content: dict, metadata: dict):
"""
Update the progress bar based on the progress message.
"""
value = msg_content["value"]
max_value = msg_content.get("max_value", 100)
done = msg_content.get("done", False)
status: Literal["open", "paused", "aborted", "halted", "closed"] = metadata.get(
"status", "open"
)
if self.task is None:
return
self.task.update(value, max_value, done)
self.update_labels()
self.progressbar.set_maximum(self.task.max_value)
self.progressbar.state = ProgressState.from_bec_status(status)
self.progressbar.set_value(self.task.value)
if done:
self.task = None
self.progress_finished.emit()
return
@SafeProperty(bool)
def show_elapsed_time(self):
@@ -132,17 +260,74 @@ class ScanProgressBar(BECWidget, QWidget):
"""
Update the labels based on the progress task.
"""
task = self.progress_tracker.task
if task is None:
if self.task is None:
return
self.ui.elapsed_time_label.setText(task.time_elapsed)
self.ui.remaining_time_label.setText(task.time_remaining)
self.ui.elapsed_time_label.setText(self.task.time_elapsed)
self.ui.remaining_time_label.setText(self.task.time_remaining)
@SafeSlot(dict, dict, verify_sender=True)
def on_queue_update(self, msg_content, metadata):
"""
Update the progress bar based on the queue status.
"""
if not "queue" in msg_content:
return
if "primary" not in msg_content["queue"]:
return
if (primary_queue := msg_content.get("queue").get("primary")) is None:
return
if not isinstance(primary_queue, messages.ScanQueueStatus):
return
primary_queue_info = primary_queue.info
if len(primary_queue_info) == 0:
return
scan_info = primary_queue_info[0]
if scan_info is None:
return
if scan_info.status.lower() == "running" and self.task is None:
self.task = ProgressTask(parent=self)
self.progress_started.emit()
active_request_block = scan_info.active_request_block
if active_request_block is None:
return
self.scan_number = active_request_block.scan_number
report_instructions = active_request_block.report_instructions
if not report_instructions:
return
# for now, let's just use the first instruction
instruction = report_instructions[0]
if "scan_progress" in instruction:
self.set_progress_source(ProgressSource.SCAN_PROGRESS)
elif "device_progress" in instruction:
device = instruction["device_progress"][0]
self.set_progress_source(ProgressSource.DEVICE_PROGRESS, device=device)
def cleanup(self):
self.progress_tracker.cleanup()
if self.task is not None:
self.task.timer.stop()
self.close()
self.deleteLater()
if self._progress_source is not None:
self.bec_dispatcher.disconnect_slot(
self.on_progress_update,
(
MessageEndpoints.scan_progress()
if self._progress_source == ProgressSource.SCAN_PROGRESS
else MessageEndpoints.device_progress(device=self._progress_device)
),
)
self._progress_source = None
self._progress_device = None
self.progressbar.close()
self.progressbar.deleteLater()
self.bec_dispatcher.disconnect_slot(
self.on_queue_update, MessageEndpoints.scan_queue_status()
)
super().cleanup()
@@ -1,607 +0,0 @@
from __future__ import annotations
import sys
from typing import Any
from bec_lib import bl_states, messages
from bec_lib.endpoints import MessageEndpoints
from bec_qthemes import material_icon
from qtpy.QtCore import QAbstractListModel, QModelIndex, QSize, Qt
from qtpy.QtWidgets import (
QAbstractItemView,
QApplication,
QDialog,
QHBoxLayout,
QLabel,
QListView,
QMessageBox,
QSizePolicy,
QStyledItemDelegate,
QStyleOptionViewItem,
QToolButton,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.widgets.services.beamline_states.beamline_state_pill import BeamlineStatePill
from bec_widgets.widgets.services.beamline_states.dialogs import (
AddBeamlineStateDialog,
DeviceFilterDialog,
StatusFilterDialog,
)
class _BeamlineStateListModel(QAbstractListModel):
"""Model owning beamline state row identity and configuration data."""
NameRole = Qt.ItemDataRole.UserRole + 1
ConfigRole = Qt.ItemDataRole.UserRole + 2
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._state_order: list[str] = []
self._state_rows: dict[str, int] = {}
self._state_configs: dict[str, messages.BeamlineStateConfig] = {}
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: # noqa: N802
return 0 if parent.isValid() else len(self._state_order)
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
if not index.isValid() or not 0 <= index.row() < len(self._state_order):
return None
name = self._state_order[index.row()]
if role in (Qt.ItemDataRole.DisplayRole, self.NameRole):
return name
if role == self.ConfigRole:
return self._state_configs.get(name)
return None
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
if not index.isValid():
return Qt.ItemFlag.NoItemFlags
return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
def set_states(self, state_configs: list[messages.BeamlineStateConfig]) -> None:
new_order = [state.name for state in state_configs]
new_configs = {state.name: state for state in state_configs}
for row in reversed(
[row for row, name in enumerate(self._state_order) if name not in new_configs]
):
self.beginRemoveRows(QModelIndex(), row, row)
name = self._state_order.pop(row)
self._state_configs.pop(name, None)
self.endRemoveRows()
self._rebuild_rows()
for target_row, name in enumerate(new_order):
if name not in self._state_rows:
self.beginInsertRows(QModelIndex(), target_row, target_row)
self._state_order.insert(target_row, name)
self._state_configs[name] = new_configs[name]
self.endInsertRows()
self._rebuild_rows()
continue
current_row = self._state_rows[name]
if current_row != target_row:
destination_row = target_row if current_row > target_row else target_row + 1
self.beginMoveRows(
QModelIndex(), current_row, current_row, QModelIndex(), destination_row
)
self._state_order.insert(target_row, self._state_order.pop(current_row))
self.endMoveRows()
self._rebuild_rows()
if self._state_configs.get(name) != new_configs[name]:
self._state_configs[name] = new_configs[name]
index = self.index(self._state_rows[name], 0)
self.dataChanged.emit(index, index, [self.ConfigRole])
def _rebuild_rows(self) -> None:
self._state_rows = {name: row for row, name in enumerate(self._state_order)}
def index_for_name(self, name: str) -> QModelIndex:
row = self._state_rows.get(name)
if row is None:
return QModelIndex()
return self.index(row, 0)
class _BeamlineStatePillDelegate(QStyledItemDelegate):
"""Delegate that provides BeamlineStatePill persistent editors for list rows."""
def __init__(self, manager: "BeamlineStateManager") -> None:
super().__init__(manager)
self._manager = manager
def paint(self, _painter, _option: QStyleOptionViewItem, _index: QModelIndex) -> None:
return
def createEditor( # noqa: N802
self, parent: QWidget, _option: QStyleOptionViewItem, index: QModelIndex
) -> QWidget:
name = index.data(_BeamlineStateListModel.NameRole)
state_config = index.data(_BeamlineStateListModel.ConfigRole)
pill = BeamlineStatePill(parent=parent, state_name=name, client=self._manager.client)
pill.idle_card_background = self._manager.idle_card_background
pill.set_state_config(state_config)
pill.state_changed.connect(self._manager._on_pill_state_changed)
pill.update_requested.connect(self._manager._update_state_parameters)
pill.remove_requested.connect(self._manager._remove_state_requested)
pill.row_height_changed.connect(lambda name=name: self._manager._sync_pill_item_size(name))
self._manager._state_pills[str(name)] = pill
return pill
def setEditorData(self, editor: QWidget, index: QModelIndex) -> None: # noqa: N802
if not isinstance(editor, BeamlineStatePill):
return
name = index.data(_BeamlineStateListModel.NameRole)
state_config = index.data(_BeamlineStateListModel.ConfigRole)
editor.set_state_name(str(name))
editor.idle_card_background = self._manager.idle_card_background
editor.set_state_config(state_config)
def updateEditorGeometry( # noqa: N802
self, editor: QWidget, option: QStyleOptionViewItem, _index: QModelIndex
) -> None:
editor.setGeometry(option.rect)
def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize: # noqa: N802
name = index.data(_BeamlineStateListModel.NameRole)
pill = self._manager._state_pills.get(str(name))
if pill is not None:
return pill.sizeHint()
return QSize(120, 58)
def destroyEditor(self, editor: QWidget, index: QModelIndex) -> None: # noqa: N802
if isinstance(editor, BeamlineStatePill):
name = editor.state_name
if name and self._manager._state_pills.get(name) is editor:
self._manager._state_pills.pop(name, None)
editor.cleanup()
super().destroyEditor(editor, index)
class _BeamlineStateListView(QListView):
"""List view using persistent pill editors."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("beamline_state_pill_view")
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.setFrameShape(QListView.Shape.NoFrame)
self.setSpacing(6)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.setStyleSheet(
"QListView#beamline_state_pill_view {"
"background: transparent;"
"border: none;"
"}"
"QListView#beamline_state_pill_view::item {"
"background: transparent;"
"border: none;"
"padding: 0;"
"}"
"QListView#beamline_state_pill_view::item:selected {"
"background: transparent;"
"border: none;"
"}"
)
class BeamlineStateManager(BECWidget, QWidget):
"""
Widget displaying and managing all BEC beamline states.
The manager subscribes to ``MessageEndpoints.available_beamline_states()`` and creates,
updates, or removes child ``BeamlineStatePill`` widgets as the set of configured states changes.
"""
PLUGIN = True
ICON_NAME = "format_list_bulleted"
USER_ACCESS = [
"clear_filters",
"collapse_all",
"state_summary",
"remove",
"attach",
"detach",
"screenshot",
]
def __init__(
self,
parent: QWidget | None = None,
client=None,
config: ConnectionConfig | None = None,
gui_id: str | None = None,
idle_card_background: bool = False,
**kwargs,
) -> None:
super().__init__(
parent=parent, client=client, config=config, gui_id=gui_id, theme_update=True, **kwargs
)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self._state_pills: dict[str, BeamlineStatePill] = {}
self._state_configs: dict[str, messages.BeamlineStateConfig] = {}
self._state_order: list[str] = []
self._selected_statuses: set[str] | None = None
self._selected_devices: set[str] | None = None
self._device_filter_text = ""
self._hidden_expanded = False
self._idle_card_background = False
self.idle_card_background = idle_card_background
self._empty_label = QLabel(
"No beamline states available.\n Add new state from toolbar or CLI.", self
)
self._empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._toolbar = self._create_toolbar()
self._model = _BeamlineStateListModel(self)
self._view = _BeamlineStateListView(self)
self._delegate = _BeamlineStatePillDelegate(self)
self._view.setModel(self._model)
self._view.setItemDelegate(self._delegate)
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)
layout.setSpacing(6)
layout.addWidget(self._toolbar)
layout.addWidget(self._empty_label)
layout.addWidget(self._view, 1)
self._hidden_summary = QToolButton(self)
self._hidden_summary.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
self._hidden_summary.setCheckable(True)
self._hidden_summary.toggled.connect(self._toggle_hidden_states)
layout.addWidget(self._hidden_summary)
self.setLayout(layout)
self.bec_dispatcher.connect_slot(
self.update_available_states, MessageEndpoints.available_beamline_states()
)
self.refresh_states()
self._refresh_hidden_summary()
@SafeProperty(bool, default=False)
def idle_card_background(self) -> bool:
"""
Whether idle collapsed pills keep the status-tinted card background.
"""
return self._idle_card_background
@idle_card_background.setter
def idle_card_background(self, enabled: bool) -> None:
self._idle_card_background = enabled
for pill in self._state_pills.values():
pill.idle_card_background = self._idle_card_background
def set_idle_card_background(self, enabled: bool) -> None:
"""Set whether idle collapsed pills keep the status-tinted card background."""
self.idle_card_background = enabled
def _create_toolbar(self) -> ModularToolBar:
toolbar = ModularToolBar(parent=self)
add_state = MaterialIconAction("add", "Add beamline state", filled=True, parent=self)
filter_states = MaterialIconAction(
"filter_alt", "Filter displayed state status", filled=True, parent=self
)
filter_devices = MaterialIconAction(
"devices", "Filter displayed devices", filled=True, parent=self
)
clear_filters = MaterialIconAction(
"filter_alt_off", "Clear beamline state filters", filled=True, parent=self
)
collapse_all = MaterialIconAction(
"collapse_all", "Collapse all states", filled=True, parent=self
)
add_state.action.triggered.connect(self.open_add_state_dialog)
filter_states.action.triggered.connect(self.open_status_filter_dialog)
filter_devices.action.triggered.connect(self.open_device_filter_dialog)
clear_filters.action.triggered.connect(self.clear_filters)
collapse_all.action.triggered.connect(self.collapse_all)
toolbar.components.add_safe("add_state", add_state)
toolbar.components.add_safe("filter_states", filter_states)
toolbar.components.add_safe("filter_devices", filter_devices)
toolbar.components.add_safe("clear_filters", clear_filters)
toolbar.components.add_safe("collapse_all", collapse_all)
bundle = ToolbarBundle("beamline_state_manager", toolbar.components)
bundle.add_action("add_state")
bundle.add_action("filter_states")
bundle.add_action("filter_devices")
bundle.add_action("clear_filters")
bundle.add_action("collapse_all")
toolbar.add_bundle(bundle)
toolbar.show_bundles(["beamline_state_manager"])
return toolbar
@SafeSlot(str)
def apply_theme(self, _theme: str) -> None:
colors = BeamlineStatePill._state_colors("unknown")
self.setStyleSheet(
"BeamlineStateManager { border: none; }"
"QToolButton#hidden_states_summary {"
f"background-color: {colors['background']};"
f"border: 1px solid {colors['border']};"
"border-radius: 6px;"
"padding: 6px;"
"text-align: left;"
"}"
)
for pill in self._state_pills.values():
pill.apply_theme(_theme)
self._refresh_hidden_summary()
@SafeSlot()
def open_add_state_dialog(self) -> None:
dialog = AddBeamlineStateDialog(self, client=self.client)
config = None
try:
accepted = dialog.exec() == QDialog.Accepted
if accepted:
config = dialog.config_result
finally:
dialog.cleanup()
dialog.deleteLater()
if config is None:
return
try:
self.client.beamline_states.add(config)
except Exception as exc:
QMessageBox.warning(self, "Cannot Add State", str(exc))
@SafeSlot()
def open_status_filter_dialog(self) -> None:
dialog = StatusFilterDialog(self._selected_statuses, self)
if dialog.exec() != QDialog.Accepted:
return
self._selected_statuses = dialog.selected_statuses()
self._apply_filters()
@SafeSlot()
def open_device_filter_dialog(self) -> None:
devices = sorted(
{
device
for state in self._state_configs.values()
if (device := self._state_device(state)) is not None
}
)
dialog = DeviceFilterDialog(devices, self._selected_devices, self._device_filter_text, self)
if dialog.exec() != QDialog.Accepted:
return
self._selected_devices = dialog.selected_devices()
self._device_filter_text = dialog.filter_text()
self._apply_filters()
@SafeSlot()
def clear_filters(self) -> None:
self._selected_statuses = None
self._selected_devices = None
self._device_filter_text = ""
self._hidden_expanded = False
self._apply_filters()
@SafeSlot()
def collapse_all(self) -> None:
"""Collapse the settings panel of all displayed state pills."""
for pill in self._state_pills.values():
pill.set_expanded(False)
def state_summary(self) -> dict[str, dict[str, str]]:
"""
Return all beamline states (including filtered ones) with their current status and label.
Returns:
dict: Mapping of state name to a dictionary with ``status`` and ``label`` keys.
"""
return {
name: {"status": pill._status, "label": pill._label}
for name, pill in self._state_pills.items()
}
@SafeSlot()
def refresh_states(self) -> None:
"""Fetch the latest cached available beamline states and update the list immediately."""
msg = self.client.connector.get_last(
MessageEndpoints.available_beamline_states(), key="data"
)
if msg is not None:
self.update_available_states(msg.content, msg.metadata)
@SafeSlot(dict, dict)
def update_available_states(
self, content: dict[str, Any], _metadata: dict[str, Any] | None = None
) -> None:
"""Update the displayed pills from ``AvailableBeamlineStatesMessage`` content."""
expanded_names = {name for name, pill in self._state_pills.items() if pill.is_expanded()}
state_configs: list[messages.BeamlineStateConfig] = content.get("states", [])
if state_configs == list(self._state_configs.values()):
self._apply_filters()
return
self._state_configs = {state.name: state for state in state_configs}
self._state_order = [state.name for state in state_configs]
self._model.set_states(state_configs)
self._open_persistent_editors(expanded_names)
self._apply_filters()
def _open_persistent_editors(self, expanded_names: set[str] | None = None) -> None:
expanded_names = expanded_names or set()
for row in range(self._model.rowCount()):
index = self._model.index(row, 0)
self._view.openPersistentEditor(index)
name = str(index.data(_BeamlineStateListModel.NameRole))
pill = self._state_pills.get(name)
if pill is not None:
pill.set_expanded(name in expanded_names)
self._sync_pill_item_size(name)
def _apply_filters(self) -> None:
visible_names = []
hidden_names = []
for name in self._state_order:
if self._is_state_visible(name):
visible_names.append(name)
else:
hidden_names.append(name)
visible_set = set(visible_names)
show_hidden = self._hidden_expanded and bool(hidden_names)
for row, name in enumerate(self._state_order):
hidden_by_filter = name not in visible_set
self._view.setRowHidden(row, hidden_by_filter and not show_hidden)
self._sync_pill_item_size(name)
self._empty_label.setVisible(
not visible_names and not (self._hidden_expanded and hidden_names)
)
self._view.setVisible(bool(visible_names) or (self._hidden_expanded and bool(hidden_names)))
self._refresh_hidden_summary(hidden_count=len(hidden_names))
def _sync_pill_item_size(self, name: str) -> None:
index = self._model.index_for_name(name)
if not index.isValid():
return
self._model.dataChanged.emit(index, index, [Qt.ItemDataRole.SizeHintRole])
self._view.update(index)
def _is_state_visible(self, name: str) -> bool:
pill = self._state_pills.get(name)
if self._selected_statuses is not None and (
pill is None or pill._status not in self._selected_statuses
):
return False
device = self._state_device(self._state_configs.get(name))
if self._selected_devices is not None and device not in self._selected_devices:
return False
tokens = [
token.strip().casefold()
for token in self._device_filter_text.split(",")
if token.strip()
]
if tokens:
if device is None:
return False
device_lower = device.casefold()
if not any(token in device_lower for token in tokens):
return False
return True
@SafeSlot(str, str, str)
def _on_pill_state_changed(self, _name: str, _status: str, _label: str) -> None:
if self._selected_statuses is not None:
self._apply_filters()
@SafeSlot(str, object)
def _update_state_parameters(
self, state_name: str, config: bl_states.BeamlineStateConfig
) -> None:
state_client = getattr(self.client.beamline_states, state_name, None)
if state_client is None:
QMessageBox.warning(
self, "Cannot Update State", f"Beamline state '{state_name}' is not available."
)
return
try:
state_client.update_parameters(**config.model_dump(exclude={"name"}))
except Exception as exc:
QMessageBox.warning(self, "Cannot Update State", str(exc))
return
pill = self._state_pills.get(state_name)
if pill is not None:
pill.mark_current_settings_clean()
@SafeSlot(str)
def _remove_state_requested(self, state_name: str) -> None:
reply = QMessageBox.question(
self,
"Remove Beamline State",
f"Remove beamline state '{state_name}'?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
try:
self.client.beamline_states.delete(state_name)
except Exception as exc:
QMessageBox.warning(self, "Cannot Remove State", str(exc))
@SafeSlot(bool)
def _toggle_hidden_states(self, checked: bool) -> None:
self._hidden_expanded = bool(checked)
self._apply_filters()
def _refresh_hidden_summary(self, hidden_count: int | None = None) -> None:
if hidden_count is None:
hidden_count = sum(1 for name in self._state_order if not self._is_state_visible(name))
self._hidden_summary.setObjectName("hidden_states_summary")
self._hidden_summary.setVisible(hidden_count > 0)
self._hidden_summary.setChecked(self._hidden_expanded and hidden_count > 0)
icon_name = "expand_less" if self._hidden_expanded else "expand_more"
self._hidden_summary.setIcon(material_icon(icon_name, convert_to_pixmap=False))
suffix = "state is" if hidden_count == 1 else "states are"
action = "Hide" if self._hidden_expanded else "Show"
self._hidden_summary.setText(
f"{hidden_count} {suffix} hidden by filters. {action} hidden states."
)
@staticmethod
def _state_device(state: messages.BeamlineStateConfig | None) -> str | None:
device = state.parameters.get("device") if state is not None else None
return str(device) if device else None
def cleanup(self) -> None:
self.bec_dispatcher.disconnect_slot(
self.update_available_states, MessageEndpoints.available_beamline_states()
)
for row in range(self._model.rowCount()):
self._view.closePersistentEditor(self._model.index(row, 0))
for pill in list(self._state_pills.values()):
pill.cleanup()
pill.deleteLater()
self._state_pills.clear()
self._toolbar.components.cleanup()
super().cleanup()
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
apply_theme("dark")
window = QWidget()
window.setWindowTitle("Beamline States")
layout = QVBoxLayout(window)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
theme_row = QHBoxLayout()
theme_row.addStretch(1)
theme_row.addWidget(DarkModeButton(parent=window))
layout.addLayout(theme_row)
layout.addWidget(BeamlineStateManager(parent=window), 1)
window.resize(760, 480)
window.show()
sys.exit(app.exec())
@@ -1 +0,0 @@
{'files': ['beamline_state_pill.py']}
@@ -1,57 +0,0 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.services.beamline_states.beamline_state_manager import BeamlineStateManager
DOM_XML = """
<ui language='c++'>
<widget class='BeamlineStateManager' name='beamline_state_manager'>
</widget>
</ui>
"""
class BeamlineStateManagerPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = BeamlineStateManager(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Services"
def icon(self):
return designer_material_icon(BeamlineStateManager.ICON_NAME)
def includeFile(self):
return "beamline_state_manager"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "BeamlineStateManager"
def toolTip(self):
return ""
def whatsThis(self):
return self.toolTip()
@@ -1,625 +0,0 @@
from __future__ import annotations
from typing import Any
from bec_lib import bl_states, messages
from bec_lib.endpoints import MessageEndpoints
from bec_qthemes import material_icon
from qtpy.QtCore import Qt, Signal
from qtpy.QtGui import QColor, QMouseEvent, QPalette
from qtpy.QtWidgets import (
QApplication,
QFormLayout,
QGraphicsDropShadowEffect,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QSizePolicy,
QToolButton,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import Colors, get_accent_colors, get_theme_name, rgba, theme_color
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.forms_from_types.pydantic_widget_form import (
OptionalValueWidget,
PydanticWidgetForm,
)
from bec_widgets.widgets.services.beamline_states.dialogs import (
BEAMLINE_STATE_STATUS_LABELS,
SUPPORTED_BEAMLINE_STATES,
)
class _BeamlineStatePillHeader(QWidget):
"""Header surface responsible for pill click gestures."""
clicked = Signal()
def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802
if event.button() == Qt.MouseButton.LeftButton:
self.clicked.emit()
event.accept()
return
super().mousePressEvent(event)
class BeamlineStatePill(BECWidget, QWidget):
"""
Compact widget showing one BEC beamline state.
The pill subscribes to ``MessageEndpoints.beamline_state(state_name)`` and updates whenever
a ``BeamlineStateMessage`` is published for that state.
"""
PLUGIN = False
RPC = False
state_changed = Signal(str, str, str)
update_requested = Signal(str, object)
remove_requested = Signal(str)
row_height_changed = Signal()
_STATUS_LABELS = BEAMLINE_STATE_STATUS_LABELS
_STATUS_ICONS = {
"valid": "check_circle",
"invalid": "cancel",
"warning": "warning",
"unknown": "help",
}
def __init__(
self,
parent: QWidget | None = None,
state_name: str | None = None,
client=None,
config: ConnectionConfig | None = None,
gui_id: str | None = None,
**kwargs,
) -> None:
super().__init__(
parent=parent, client=client, config=config, gui_id=gui_id, theme_update=True, **kwargs
)
self.setObjectName("BeamlineStatePill")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
self._state_name: str | None = None
self._state_config: messages.BeamlineStateConfig | None = None
self._status = "unknown"
self._label = "No state information available."
self._expanded = False
self._idle_card_background = False
self._populating_settings = False
self._settings_baseline: dict[str, Any] = {}
self._settings_dirty_fields: set[str] = set()
self._settings_form_stale = True
self._init_ui(state_name)
def _init_ui(self, state_name: str | None = None) -> None:
self._shadow = QGraphicsDropShadowEffect(self)
self._shadow.setBlurRadius(18)
self._shadow.setOffset(0, 2)
self._shadow.setColor(QColor(0, 0, 0, 120))
self._shadow.setEnabled(False)
self.setGraphicsEffect(self._shadow)
self._header = _BeamlineStatePillHeader(self)
self._header.setObjectName("beamline_state_header")
self._header.setCursor(Qt.CursorShape.PointingHandCursor)
self._header.clicked.connect(self._toggle_expanded)
self._stripe = QWidget(self)
self._stripe.setObjectName("beamline_state_stripe")
self._stripe.setFixedWidth(4)
self._stripe.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding)
self._stripe.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
self._icon_label = QLabel(self)
self._icon_label.setObjectName("beamline_state_icon")
self._icon_label.setFixedSize(32, 32)
self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._icon_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
self._name_label = QLabel(self)
self._name_label.setObjectName("beamline_state_name")
self._name_label.setTextFormat(Qt.TextFormat.PlainText)
self._name_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
self._status_label = QLabel(self)
self._status_label.setObjectName("beamline_state_status")
self._status_label.setTextFormat(Qt.TextFormat.PlainText)
self._status_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
self._detail_label = QLabel(self)
self._detail_label.setObjectName("beamline_state_detail")
self._detail_label.setTextFormat(Qt.TextFormat.PlainText)
self._detail_label.setWordWrap(True)
self._detail_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
self._expand_button = QToolButton(self)
self._expand_button.setObjectName("beamline_state_expand")
self._expand_button.setAutoRaise(True)
self._expand_button.setCursor(Qt.CursorShape.PointingHandCursor)
self._expand_button.clicked.connect(self._toggle_expanded)
text_layout = QVBoxLayout()
text_layout.setContentsMargins(0, 0, 0, 0)
text_layout.setSpacing(1)
text_layout.addWidget(self._name_label)
text_layout.addWidget(self._detail_label)
header_layout = QHBoxLayout(self._header)
header_layout.setContentsMargins(10, 8, 12, 8)
header_layout.setSpacing(10)
header_layout.addWidget(self._stripe)
header_layout.addWidget(self._icon_label)
header_layout.addLayout(text_layout, 1)
header_layout.addWidget(self._status_label, 0, Qt.AlignmentFlag.AlignRight)
header_layout.addWidget(self._expand_button)
self._settings = QWidget(self)
self._settings.setObjectName("beamline_state_settings")
self._settings.setVisible(False)
self._state_type_value = QLabel(self._settings)
self._config_form: PydanticWidgetForm | None = None
self._config_form_host = QVBoxLayout()
self._config_form_host.setContentsMargins(0, 0, 0, 0)
self._config_form_host.setSpacing(0)
button_layout = QHBoxLayout()
button_layout.setContentsMargins(0, 0, 0, 0)
button_layout.setSpacing(8)
self._update_button = QPushButton("Update", self._settings)
self._update_button.setIcon(material_icon("save", convert_to_pixmap=False))
self._revert_button = QPushButton("Revert", self._settings)
self._revert_button.setIcon(material_icon("undo", convert_to_pixmap=False))
self._remove_button = QPushButton("Remove", self._settings)
self._remove_button.setObjectName("beamline_state_remove_button")
self._remove_button.setIcon(material_icon("delete", convert_to_pixmap=False))
self._update_button.clicked.connect(self._emit_update_requested)
self._revert_button.clicked.connect(self._revert_settings)
self._remove_button.clicked.connect(self._emit_remove_requested)
button_layout.addWidget(self._update_button)
button_layout.addWidget(self._revert_button)
button_layout.addWidget(self._remove_button)
button_layout.addStretch(1)
self._settings_form = QFormLayout()
self._settings_form.setContentsMargins(0, 0, 0, 0)
self._settings_form.setHorizontalSpacing(10)
self._settings_form.setVerticalSpacing(8)
self._settings_form.setFieldGrowthPolicy(
QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow
)
self._settings_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
self._settings_form.addRow("Type", self._state_type_value)
settings_layout = QVBoxLayout(self._settings)
settings_layout.setContentsMargins(12, 8, 12, 12)
settings_layout.setSpacing(8)
settings_layout.addLayout(self._settings_form)
settings_layout.addLayout(self._config_form_host)
settings_layout.addLayout(button_layout)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(self._header)
layout.addWidget(self._settings)
self.setLayout(layout)
self._settings.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
self.set_state_name(state_name)
self._update_button.setEnabled(False)
self._revert_button.setEnabled(False)
@SafeProperty(str, default=None)
def state_name(self) -> str | None:
"""Name of the BEC beamline state displayed by this pill."""
return self._state_name
@state_name.setter
def state_name(self, state_name: str | None) -> None:
self.set_state_name(state_name)
def set_state_name(self, state_name: str | None) -> None:
"""
Set the BEC beamline state this pill displays.
Args:
state_name: State name as published by ``AvailableBeamlineStatesMessage``.
"""
if state_name == self._state_name:
return
if self._state_name is not None:
self.bec_dispatcher.disconnect_slot(
self.update_state, MessageEndpoints.beamline_state(self._state_name)
)
self._state_name = state_name
self._name_label.setText(state_name or "Beamline state")
if self._state_name is None:
self._set_visual_state("unknown", "No beamline state selected.")
return
self._set_visual_state("unknown", "No state information available.")
self._refresh_latest_state()
self.bec_dispatcher.connect_slot(
self.update_state, MessageEndpoints.beamline_state(self._state_name)
)
def set_state_config(self, state_config: messages.BeamlineStateConfig | None) -> None:
"""Set the editable BEC state configuration displayed by the expanded panel."""
self._state_config = state_config
self._settings_form_stale = True
if self._config_form is not None:
self._populate_settings()
self.mark_current_settings_clean()
@SafeProperty(bool, default=False)
def idle_card_background(self) -> bool:
"""
Whether idle collapsed pills keep the status-tinted card background.
"""
return self._idle_card_background
@idle_card_background.setter
def idle_card_background(self, enabled: bool) -> None:
self._idle_card_background = enabled
self._apply_visual_state()
def set_idle_card_background(self, enabled: bool) -> None:
"""Set whether idle collapsed pills keep the status-tinted card background."""
self.idle_card_background = enabled
def _refresh_latest_state(self) -> None:
if self._state_name is None:
return
msg = self.client.connector.get_last(
MessageEndpoints.beamline_state(self._state_name), key="data"
)
if msg is not None:
self.update_state(msg.content, msg.metadata)
@SafeSlot(dict, dict)
def update_state(
self, content: dict[str, Any], _metadata: dict[str, Any] | None = None
) -> None:
"""
Update this pill from a ``BeamlineStateMessage`` content dictionary.
"""
name = content.get("name")
if self._state_name is not None and name and name != self._state_name:
return
status = str(content.get("status", "unknown")).lower()
label = str(content.get("label", "No state information available."))
self._set_visual_state(status, label)
self.state_changed.emit(self._state_name or str(name or ""), status, label)
@SafeSlot(str)
def apply_theme(self, _theme: str) -> None:
self._apply_visual_state()
def _set_visual_state(self, status: str, label: str) -> None:
status = status if status in self._STATUS_LABELS else "unknown"
self._status = status
self._label = label
self._apply_visual_state()
def _apply_visual_state(self) -> None:
colors = self._state_colors(self._status)
accent = colors["accent"]
on_accent = colors["on_accent"]
active_card = self._expanded
border = colors["border"] if self._idle_card_background else "transparent"
background = colors["background"] if self._idle_card_background else "transparent"
card_gradient = (
"qlineargradient("
"x1:0, y1:0, x2:1, y2:0, "
f"stop:0 {colors['gradient_accent']}, "
f"stop:{colors['gradient_stop']} {colors['card_background']}, "
f"stop:1 {colors['card_background']}"
")"
)
if active_card:
background = card_gradient
border = colors["card_border"]
hover_background = card_gradient
self._shadow.setColor(QColor(colors["shadow"]))
self._shadow.setBlurRadius(int(colors["shadow_blur"]))
self._shadow.setOffset(0, int(colors["shadow_y_offset"]))
self._shadow.setEnabled(active_card)
icon_name = self._STATUS_ICONS[self._status]
self._icon_label.setPixmap(
material_icon(icon_name, size=(20, 20), color=on_accent, filled=True)
)
expand_icon = "expand_less" if self._expanded else "expand_more"
self._expand_button.setIcon(material_icon(expand_icon, convert_to_pixmap=False))
self._status_label.setText(self._STATUS_LABELS[self._status])
self._detail_label.setText(self._label)
self.setToolTip(self._label)
self.setStyleSheet(
"#BeamlineStatePill {"
f"background: {background};"
f"border: 1px solid {border};"
f"border-radius: {'12px' if active_card else '8px'};"
"}"
"#BeamlineStatePill:hover {"
f"background: {hover_background};"
f"border: 1px solid {colors['card_border']};"
"border-radius: 12px;"
"}"
"QWidget#beamline_state_header {"
"background: transparent;"
"}"
"QWidget#beamline_state_stripe {"
f"background-color: {accent};"
"border-radius: 2px;"
"}"
"QLabel#beamline_state_icon {"
f"background-color: {accent};"
"border-radius: 16px;"
"}"
"QLabel#beamline_state_name {"
f"color: {colors['foreground']};"
"font-weight: 600;"
"}"
"QLabel#beamline_state_status {"
f"color: {accent};"
"font-weight: 700;"
"font-size: 13px;"
"}"
"QLabel#beamline_state_detail {"
f"color: {colors['muted']};"
"font-size: 11px;"
"}"
"QWidget#beamline_state_settings {"
"background: transparent;"
f"border-top: 1px solid {colors['border']};"
"}"
'*[beamlineStateDirty="true"] {'
f"background-color: {colors['dirty_background']};"
f"border: 1px solid {colors['dirty_border']};"
"border-radius: 4px;"
"}"
"QPushButton#beamline_state_remove_button {"
"background-color: #cc181e;"
"border: 1px solid #cc181e;"
"color: white;"
"border-radius: 4px;"
"padding: 4px 10px;"
"}"
"QPushButton#beamline_state_remove_button:hover {"
"background-color: #a91419;"
"border-color: #a91419;"
"}"
)
@SafeSlot()
def _toggle_expanded(self) -> None:
self.set_expanded(not self._expanded)
def is_expanded(self) -> bool:
"""Return whether the editable settings panel is expanded."""
return self._expanded
def set_expanded(self, expanded: bool) -> None:
"""
Set the editable settings panel expanded state.
The settings form is built on demand when the panel expands and released again on
collapse, so collapsed pills do not keep live device/signal widgets and their BEC
subscriptions around. Unsaved edits are discarded on collapse.
"""
expanded = bool(expanded)
if expanded == self._expanded:
return
if expanded:
self._ensure_settings_form_current()
self._expanded = expanded
self._settings.setVisible(expanded)
if not expanded:
self._release_config_form()
self._apply_visual_state()
self.row_height_changed.emit()
def _ensure_config_form(
self, config_class: type[bl_states.BeamlineStateConfig] = bl_states.DeviceStateConfig
) -> PydanticWidgetForm:
if self._config_form is None:
self._config_form = PydanticWidgetForm(
config_class, parent=self._settings, client=self.client, read_only_fields={"name"}
)
self._config_form.changed.connect(self._update_settings_dirty_state)
self._config_form_host.addWidget(self._config_form)
return self._config_form
def _ensure_settings_form_current(self) -> PydanticWidgetForm:
if self._settings_form_stale:
self._populate_settings()
self.mark_current_settings_clean()
return self._ensure_config_form()
def _release_config_form(self) -> None:
if self._config_form is None:
return
self._config_form_host.removeWidget(self._config_form)
self._config_form.cleanup()
self._config_form.setParent(None)
self._config_form.deleteLater()
self._config_form = None
self._settings_baseline = {}
self._settings_form_stale = True
self._update_settings_dirty_state()
def _populate_settings(self) -> None:
self._populating_settings = True
try:
state_type = self._state_config.state_type if self._state_config is not None else ""
config_class = None
for state_class in SUPPORTED_BEAMLINE_STATES:
if state_type in {state_class.__name__, state_class.CONFIG_CLASS.state_type}:
config_class = state_class.CONFIG_CLASS
break
if config_class is None:
raise ValueError(f"Unsupported beamline state type '{state_type}'.")
config_form = self._ensure_config_form(config_class)
if config_form.model is not config_class:
config_form.set_model(config_class)
self._state_type_value.setText(state_type or "-")
config_form.set_partial_data(self._state_data_for_form(config_class))
self._settings_form_stale = False
finally:
self._populating_settings = False
self._update_settings_dirty_state()
def edited_config(self) -> bl_states.BeamlineStateConfig:
"""Return the validated config currently represented by the expanded settings panel."""
config = self._ensure_settings_form_current().model_instance()
return config # type: ignore[return-value]
def mark_current_settings_clean(self) -> None:
"""Mark the current editor values as saved."""
config_form = self._ensure_config_form()
self._settings_baseline = config_form.raw_editable_data()
config_form.mark_clean()
self._update_settings_dirty_state()
@SafeSlot()
def _revert_settings(self) -> None:
self._populating_settings = True
try:
self._ensure_config_form().set_partial_data(self._settings_baseline)
finally:
self._populating_settings = False
self._update_settings_dirty_state()
def _update_settings_dirty_state(self) -> None:
if self._populating_settings:
return
if self._config_form is None:
self._settings_dirty_fields = set()
self._update_button.setEnabled(False)
self._revert_button.setEnabled(False)
return
self._settings_dirty_fields = self._config_form.dirty_fields() - {"name"}
has_changes = bool(self._settings_dirty_fields)
self._update_button.setEnabled(has_changes)
self._revert_button.setEnabled(has_changes)
self._apply_dirty_field_highlights()
def _apply_dirty_field_highlights(self) -> None:
if self._config_form is None:
return
for name, widget in self._config_form.widgets.items():
self._set_dirty_property(widget, name in self._settings_dirty_fields)
@staticmethod
def _set_dirty_property(widget: QWidget, dirty: bool) -> None:
widgets = [widget]
if isinstance(widget, OptionalValueWidget):
widgets.append(widget.value_widget)
if widget.value_widget.parentWidget() is not None:
widgets.append(widget.value_widget.parentWidget())
for target in widgets:
if target.property("beamlineStateDirty") == dirty:
continue
target.setProperty("beamlineStateDirty", dirty)
target.style().unpolish(target)
target.style().polish(target)
target.update()
@SafeSlot()
def _emit_update_requested(self) -> None:
if self._state_name is None:
return
if not self._settings_dirty_fields:
return
try:
config = self.edited_config()
except ValueError as exc:
QMessageBox.warning(self, "Invalid Beamline State", str(exc))
return
self.update_requested.emit(self._state_name, config)
@SafeSlot()
def _emit_remove_requested(self) -> None:
if self._state_name is None:
return
self.remove_requested.emit(self._state_name)
def _state_data_for_form(
self, config_class: type[bl_states.BeamlineStateConfig]
) -> dict[str, Any]:
data: dict[str, Any] = {}
parameters = self._state_config.parameters if self._state_config is not None else {}
for name in config_class.model_fields:
if name == "name":
data[name] = self._state_name
elif name in parameters:
data[name] = parameters[name]
return data
@staticmethod
def _state_colors(status: str) -> dict[str, str]:
app = QApplication.instance()
palette = app.palette() if app is not None else QPalette()
theme = getattr(app, "theme", None) if app is not None else None
light_theme = get_theme_name() == "light"
accents = get_accent_colors()
card_bg = theme_color(theme, "CARD_BG", palette.window().color())
border = theme_color(theme, "BORDER", palette.mid().color())
foreground = theme_color(theme, "FG", palette.text().color())
on_primary = theme_color(theme, "ON_PRIMARY", QColor("#ffffff"))
warning = accents.warning
accent = {
"valid": accents.success,
"invalid": accents.emergency,
"warning": warning,
"unknown": accents.default,
}.get(status, accents.default)
gradient_alpha = 18 if light_theme else 62
gradient_stop = "0.38" if light_theme else "0.62"
background_mix = 0.0 if light_theme else 0.10
card_border_mix = 0.34 if light_theme else 0.45
border_mix = 0.34 if light_theme else 0.35
return {
"accent": accent.name(),
"on_accent": on_primary.name(),
"card_background": card_bg.name(),
"card_border": Colors._blend(border, accent, card_border_mix).name(),
"gradient_accent": rgba(accent, gradient_alpha),
"gradient_stop": gradient_stop,
"background": Colors._blend(card_bg, accent, background_mix).name(),
"border": Colors._blend(border, accent, border_mix).name(),
"dirty_background": Colors._blend(
card_bg, warning, 0.12 if light_theme else 0.18
).name(),
"dirty_border": Colors._blend(border, warning, 0.70).name(),
"foreground": foreground.name(),
"muted": Colors._blend(card_bg, foreground, 0.66).name(),
"shadow": "#00000024" if light_theme else "#00000078",
"shadow_blur": "24" if light_theme else "18",
"shadow_y_offset": "3" if light_theme else "2",
}
def cleanup(self) -> None:
if self._state_name is not None:
self.bec_dispatcher.disconnect_slot(
self.update_state, MessageEndpoints.beamline_state(self._state_name)
)
if self._config_form is not None:
self._config_form.cleanup()
super().cleanup()
@@ -1,263 +0,0 @@
from __future__ import annotations
import slugify
from bec_lib import bl_states
from qtpy.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLineEdit,
QMessageBox,
QPushButton,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.forms_from_types.pydantic_widget_form import PydanticWidgetForm
from bec_widgets.utils.name_utils import pascal_to_snake
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
BEAMLINE_STATE_STATUS_LABELS = {
"valid": "VALID",
"invalid": "INVALID",
"warning": "WARNING",
"unknown": "UNKNOWN",
}
SUPPORTED_BEAMLINE_STATES: tuple[type[bl_states.BeamlineState], ...] = (
bl_states.DeviceWithinLimitsState,
bl_states.ShutterState,
)
class AddBeamlineStateDialog(QDialog):
"""Dialog for creating supported beamline state configurations."""
def __init__(self, parent: QWidget | None = None, client=None) -> None:
super().__init__(parent=parent)
self.setWindowTitle("Add Beamline State")
self._cleaned_up = False
self._client = client
self._config: bl_states.BeamlineStateConfig | None = None
self._auto_generated_name: str | None = None
self._type_combo = QComboBox(self)
for state_class in SUPPORTED_BEAMLINE_STATES:
self._type_combo.addItem(state_class.__name__, state_class)
self._type_combo.currentIndexChanged.connect(self._update_config_form)
self._form = QFormLayout()
self._form.addRow("State type", self._type_combo)
self._config_form_host = QVBoxLayout()
self._config_form: PydanticWidgetForm | None = None
self._buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self
)
self._buttons.accepted.connect(self.accept)
self._buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.addLayout(self._form)
layout.addLayout(self._config_form_host)
layout.addWidget(self._buttons)
self.setLayout(layout)
self._update_config_form()
self._fit_to_contents()
def config(self) -> bl_states.BeamlineStateConfig:
state_class = self._selected_state_class()
config_class = state_class.CONFIG_CLASS
name = self._state_name()
data = self._config_form.get_data()
data["name"] = name
return config_class.model_validate(data)
def accept(self) -> None:
try:
self._config = self.config()
except Exception as exc:
QMessageBox.warning(self, "Invalid Beamline State", str(exc))
return
super().accept()
@property
def config_result(self) -> bl_states.BeamlineStateConfig:
if self._config is None:
raise RuntimeError("Beamline state dialog was not accepted with a valid config.")
return self._config
def cleanup(self) -> None:
if self._cleaned_up:
return
self._cleaned_up = True
if self._config_form is not None:
self._config_form.cleanup()
self._config_form.close()
self._config_form.deleteLater()
def closeEvent(self, event) -> None: # noqa: N802
self.cleanup()
super().closeEvent(event)
@SafeSlot(str)
def _on_valid_device_selected(self, device: str) -> None:
if self._cleaned_up:
return
name_widget = self._config_form.input_widget("name")
current_name = name_widget.text().strip()
if current_name and current_name != self._auto_generated_name:
return
suffix = slugify.slugify(
pascal_to_snake(self._selected_state_class().__name__), separator="_"
)
generated_name = f"{slugify.slugify(device, separator='_')}_{suffix}"
self._auto_generated_name = generated_name
name_widget.setText(generated_name)
@SafeSlot(int)
def _update_config_form(self, _index: int = 0) -> None:
previous_data = self._config_form.raw_data() if self._config_form is not None else {}
if self._config_form is not None:
self._config_form_host.removeWidget(self._config_form)
self._config_form.cleanup()
self._config_form.setParent(None)
self._config_form.deleteLater()
config_class = self._selected_state_class().CONFIG_CLASS
data = {
key: value
for key, value in previous_data.items()
if key in config_class.model_fields and value is not None
}
self._config_form = PydanticWidgetForm(config_class, parent=self, client=self._client)
self._config_form.set_partial_data(data)
self._config_form_host.addWidget(self._config_form)
for device_widget in self._config_form.input_widgets_by_type(DeviceComboBox):
device_widget.device_selected.connect(self._on_valid_device_selected)
self._fit_to_contents()
def _fit_to_contents(self) -> None:
self.setMinimumSize(0, 0)
self.setMaximumSize(16777215, 16777215)
self.layout().activate()
self.setFixedSize(self.sizeHint().expandedTo(self.minimumSizeHint()))
def _selected_state_class(self) -> type[bl_states.BeamlineState]:
state_class = self._type_combo.currentData()
if state_class is None:
raise RuntimeError("No beamline state class selected.")
return state_class
def _state_name(self) -> str:
name_widget = self._config_form.input_widget("name")
raw_name = name_widget.text().strip()
if not raw_name:
raise ValueError("Name is required.")
name = slugify.slugify(raw_name, separator="_")
name_widget.setText(name)
return name
class StatusFilterDialog(QDialog):
"""Dialog for selecting visible beamline state statuses."""
def __init__(self, selected_statuses: set[str] | None, parent: QWidget | None = None) -> None:
super().__init__(parent=parent)
self.setWindowTitle("Filter Beamline State Status")
self._checkboxes: dict[str, QCheckBox] = {}
controls = QHBoxLayout()
select_all = QPushButton("Select all", self)
clear = QPushButton("Clear", self)
select_all.clicked.connect(lambda: self._set_all(True))
clear.clicked.connect(lambda: self._set_all(False))
controls.addWidget(select_all)
controls.addWidget(clear)
controls.addStretch(1)
list_layout = QVBoxLayout()
for status, label in BEAMLINE_STATE_STATUS_LABELS.items():
checkbox = QCheckBox(label, self)
checkbox.setChecked(selected_statuses is None or status in selected_statuses)
self._checkboxes[status] = checkbox
list_layout.addWidget(checkbox)
list_layout.addStretch(1)
box = QGroupBox("Displayed status", self)
box.setLayout(list_layout)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.addLayout(controls)
layout.addWidget(box)
layout.addWidget(buttons)
self.setLayout(layout)
def selected_statuses(self) -> set[str] | None:
selected = {status for status, checkbox in self._checkboxes.items() if checkbox.isChecked()}
if selected == set(self._checkboxes):
return None
return selected
def _set_all(self, checked: bool) -> None:
for checkbox in self._checkboxes.values():
checkbox.setChecked(checked)
class DeviceFilterDialog(QDialog):
"""Dialog for filtering beamline states by configured device."""
def __init__(
self,
devices: list[str],
selected_devices: set[str] | None,
device_filter_text: str,
parent: QWidget | None = None,
) -> None:
super().__init__(parent=parent)
self.setWindowTitle("Filter Beamline State Devices")
self._checkboxes: dict[str, QCheckBox] = {}
self._device_text = QLineEdit(self)
self._device_text.setPlaceholderText("Device name or comma-separated names")
self._device_text.setText(device_filter_text)
list_layout = QVBoxLayout()
for device in devices:
checkbox = QCheckBox(device, self)
checkbox.setChecked(selected_devices is not None and device in selected_devices)
self._checkboxes[device] = checkbox
list_layout.addWidget(checkbox)
list_layout.addStretch(1)
box = QGroupBox("Known devices", self)
box.setLayout(list_layout)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.addWidget(self._device_text)
layout.addWidget(box)
layout.addWidget(buttons)
self.setLayout(layout)
def selected_devices(self) -> set[str] | None:
selected = {device for device, checkbox in self._checkboxes.items() if checkbox.isChecked()}
return selected or None
def filter_text(self) -> str:
return self._device_text.text().strip()
@@ -1,17 +0,0 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.services.beamline_states.beamline_state_manager_plugin import (
BeamlineStateManagerPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(BeamlineStateManagerPlugin())
if __name__ == "__main__": # pragma: no cover
main()
+9 -35
View File
@@ -1,6 +1,6 @@
import sys
from qtpy.QtCore import Property, QEasingCurve, QEvent, QPointF, QPropertyAnimation, Qt, Signal
from qtpy.QtCore import Property, QEasingCurve, QPointF, QPropertyAnimation, Qt, Signal
from qtpy.QtGui import QColor, QPainter
from qtpy.QtWidgets import QApplication, QWidget
@@ -41,22 +41,10 @@ class ToggleSwitch(QWidget):
theme = getattr(QApplication.instance(), "theme", None)
colors = theme.colors if theme else {}
self._active_track_color = self._theme_color(colors, "PRIMARY", QColor(33, 150, 243))
self._active_thumb_color = self._theme_color(colors, "ON_PRIMARY", QColor(255, 255, 255))
self._inactive_track_color = self._theme_color(colors, "SEPARATOR", QColor(200, 200, 200))
self._inactive_thumb_color = self._theme_color(colors, "ON_PRIMARY", QColor(255, 255, 255))
self._disabled_track_color = self._theme_color(colors, "DISABLED_BG", QColor(220, 220, 220))
self._disabled_thumb_color = self._theme_color(colors, "DISABLED_FG", QColor(150, 150, 150))
self._disabled_border_color = self._theme_color(
colors, "DISABLED_BORDER", QColor(170, 170, 170)
)
if hasattr(self, "_checked"):
self.update_colors()
@staticmethod
def _theme_color(colors: dict, key: str, fallback: QColor) -> QColor:
color = colors.get(key, fallback)
return color if isinstance(color, QColor) else QColor(color)
self._active_track_color = colors.get("PRIMARY", QColor(33, 150, 243))
self._active_thumb_color = colors.get("ON_PRIMARY", QColor(255, 255, 255))
self._inactive_track_color = colors.get("SEPARATOR", QColor(200, 200, 200))
self._inactive_thumb_color = colors.get("ON_PRIMARY", QColor(255, 255, 255))
@Property(bool)
def checked(self):
@@ -131,40 +119,29 @@ class ToggleSwitch(QWidget):
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setRenderHint(QPainter.Antialiasing)
# Draw track
painter.setBrush(self._track_color)
painter.setPen(self._disabled_border_color if not self.isEnabled() else Qt.PenStyle.NoPen)
painter.setPen(Qt.NoPen)
painter.drawRoundedRect(
0, 0, self.width(), self.height(), self.height() / 2, self.height() / 2
)
# Draw thumb
painter.setBrush(self._thumb_color)
painter.setPen(Qt.PenStyle.NoPen)
diameter = int(self.height() * 0.8)
painter.drawEllipse(int(self._thumb_pos.x()), int(self._thumb_pos.y()), diameter, diameter)
def mousePressEvent(self, event):
if self.isEnabled() and event.button() == Qt.MouseButton.LeftButton:
if event.button() == Qt.LeftButton:
self.checked = not self.checked
def update_colors(self):
if not self.isEnabled():
self._thumb_color = self._disabled_thumb_color
self._track_color = self._disabled_track_color
return
self._thumb_color = self.active_thumb_color if self._checked else self.inactive_thumb_color
self._track_color = self.active_track_color if self._checked else self.inactive_track_color
def changeEvent(self, event):
if event.type() == QEvent.Type.EnabledChange:
self.update_colors()
self.update()
super().changeEvent(event)
def get_thumb_pos(self, checked):
return QPointF(self.width() - self.height() + 3, 2) if checked else QPointF(3, 2)
@@ -190,7 +167,7 @@ class ToggleSwitch(QWidget):
if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QHBoxLayout, QWidget
from qtpy.QtWidgets import QHBoxLayout, QVBoxLayout, QWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
@@ -200,12 +177,9 @@ if __name__ == "__main__": # pragma: no cover
widget = QWidget()
layout = QHBoxLayout(widget)
toggle = ToggleSwitch()
toggle_disabled = ToggleSwitch()
dark_mode_btn = DarkModeButton()
layout.addWidget(toggle)
layout.addWidget(toggle_disabled)
layout.addWidget(dark_mode_btn)
toggle_disabled.setEnabled(False)
window = QWidget()
window.setLayout(layout)
window.show()
+3 -14
View File
@@ -1,6 +1,6 @@
[project]
name = "bec_widgets"
version = "3.15.0"
version = "3.12.0"
description = "BEC Widgets"
requires-python = ">=3.11"
classifiers = [
@@ -12,8 +12,8 @@ dependencies = [
"PyJWT~=2.9",
"PySide6==6.9.0",
"PySide6-QtAds==4.4.0",
"bec_ipython_client~=3.134", # needed for jupyter console
"bec_lib~=3.134",
"bec_ipython_client~=3.107,>=3.107.2", # needed for jupyter console
"bec_lib~=3.107,>=3.107.2",
"bec_qthemes~=1.0, >=1.3.4",
"black>=26,<27", # needed for bw-generate-cli
"copier~=9.7",
@@ -24,7 +24,6 @@ dependencies = [
"pydantic~=2.0",
"pylsp-bec~=1.2",
"pyqtgraph==0.13.7",
"python-slugify~=8.0",
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"qtmonaco~=0.8, >=0.8.1",
"qtpy~=2.4",
@@ -66,16 +65,6 @@ qtermwidget = ["pyside6_qtermwidget"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
+1 -1
View File
@@ -45,7 +45,7 @@ def connected_client_gui_obj(qtbot, gui_id, bec_client_lib):
"""
gui = BECGuiClient(gui_id=gui_id)
try:
gui.show(wait=True)
gui.start(wait=True)
qtbot.waitUntil(lambda: hasattr(gui, "bec"), timeout=5000)
gui.bec.delete_all() # ensure clean state
qtbot.waitUntil(lambda: len(gui.bec.widget_list()) == 0, timeout=10000)
@@ -1,73 +0,0 @@
from __future__ import annotations
import uuid
import pytest
from bec_lib.bl_states import DeviceWithinLimitsStateConfig
# pylint: disable=unused-argument
# pylint: disable=redefined-outer-name
# pylint: disable=protected-access
def _delete_state_if_present(bec, state_name: str) -> None:
if hasattr(bec.beamline_states, state_name):
bec.beamline_states.delete(state_name)
@pytest.mark.timeout(100)
def test_beamline_state_manager_adds_updates_and_deletes_state_e2e(
qtbot, bec_client_lib, connected_client_gui_obj
):
"""
Verify the real BEC beamline-state flow is reflected by a BeamlineStateManager
running in the GUI server, accessed through the dock area RPC interface.
"""
gui = connected_client_gui_obj
dock_area = gui.bec
bec = bec_client_lib
dev = bec.device_manager.devices
scans = bec.scans
state_name = f"samx_widget_limits_{uuid.uuid4().hex[:8]}"
config = DeviceWithinLimitsStateConfig(
name=state_name, device="samx", signal="samx", low_limit=0.0, high_limit=10.0, tolerance=1.0
)
manager = dock_area.new("BeamlineStateManager")
qtbot.waitUntil(lambda: manager._gui_id in gui._server_registry, timeout=5000)
def state_entry() -> dict[str, str]:
return manager.state_summary().get(state_name, {})
_delete_state_if_present(bec, state_name)
try:
bec.beamline_states.add(config)
qtbot.waitUntil(lambda: hasattr(bec.beamline_states, state_name), timeout=10000)
qtbot.waitUntil(lambda: state_name in manager.state_summary(), timeout=10000)
scans.umv(dev.samx, 5, relative=False).wait()
qtbot.waitUntil(
lambda: getattr(bec.beamline_states, state_name).get()["status"] == "valid",
timeout=10000,
)
qtbot.waitUntil(lambda: state_entry().get("status") == "valid", timeout=10000)
assert state_entry()["label"] == "Device samx within limits"
scans.umv(dev.samx, 20, relative=False).wait()
qtbot.waitUntil(
lambda: getattr(bec.beamline_states, state_name).get()["status"] == "invalid",
timeout=10000,
)
qtbot.waitUntil(lambda: state_entry().get("status") == "invalid", timeout=10000)
assert state_entry()["label"] == "Device samx out of limits"
bec.beamline_states.delete(state_name)
qtbot.waitUntil(lambda: not hasattr(bec.beamline_states, state_name), timeout=10000)
qtbot.waitUntil(lambda: state_name not in manager.state_summary(), timeout=10000)
finally:
_delete_state_if_present(bec, state_name)
scans.umv(dev.samx, 0, relative=False).wait()
+4 -4
View File
@@ -143,11 +143,11 @@ def test_rpc_gui_obj(connected_client_gui_obj: BECGuiClient, qtbot):
qtbot.wait(500)
gui.kill_server()
assert not gui._gui_is_alive()
gui.show(wait=True)
gui.start(wait=True)
assert gui._gui_is_alive()
# calling show multiple times should not change anything
gui.show(wait=True)
gui.show(wait=True)
# calling start multiple times should not change anything
gui.start(wait=True)
gui.start(wait=True)
def wait_for_gui_started():
return "bec" in gui.windows
+1 -1
View File
@@ -75,7 +75,7 @@ def connected_client_gui_obj(qtbot_scope_module, gui_id, bec_client_lib):
"""
gui = BECGuiClient(gui_id=gui_id)
try:
gui.show(wait=True)
gui.start(wait=True)
qtbot_scope_module.waitUntil(lambda: hasattr(gui, "bec"), timeout=5000)
gui.bec.delete_all() # ensure clean state
qtbot_scope_module.waitUntil(lambda: len(gui.bec.widget_list()) == 0, timeout=10000)
@@ -1,588 +0,0 @@
import shiboken6
from bec_lib import bl_states, messages
from qtpy.QtCore import QCoreApplication, QEvent, Qt
from qtpy.QtWidgets import QMessageBox
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.services.beamline_states import beamline_state_manager as manager_module
from bec_widgets.widgets.services.beamline_states import beamline_state_pill as pill_module
from bec_widgets.widgets.services.beamline_states.beamline_state_manager import BeamlineStateManager
from bec_widgets.widgets.services.beamline_states.beamline_state_pill import BeamlineStatePill
from bec_widgets.widgets.services.beamline_states.dialogs import AddBeamlineStateDialog
from .client_mocks import mocked_client
from .conftest import create_widget
def _state(name: str, state_type: str, parameters: dict | None = None):
return messages.BeamlineStateConfig(
name=name, state_type=state_type, parameters=parameters or {}
)
def _wire_state(
state_class: type[bl_states.BeamlineState], config: bl_states.BeamlineStateConfig
) -> messages.BeamlineStateConfig:
return messages.BeamlineStateConfig(
name=config.name,
state_type=state_class.__name__,
parameters=config.model_dump(exclude={"name"}),
)
def _limits_state(name: str = "limits", **overrides) -> messages.BeamlineStateConfig:
values = {
"device": "samx",
"signal": "samx",
"low_limit": 0.0,
"high_limit": 10.0,
"tolerance": 0.1,
}
values.update(overrides)
config = bl_states.DeviceWithinLimitsState.CONFIG_CLASS(name=name, **values)
return _wire_state(bl_states.DeviceWithinLimitsState, config)
def _shutter_state(
name: str = "shutter_open", device: str = "samy"
) -> messages.BeamlineStateConfig:
config = bl_states.ShutterState.CONFIG_CLASS(name=name, device=device)
return _wire_state(bl_states.ShutterState, config)
def test_beamline_state_pill_updates_from_message(qtbot, mocked_client):
pill = create_widget(qtbot, BeamlineStatePill, state_name="shutter_open", client=mocked_client)
pill.update_state({"name": "shutter_open", "status": "valid", "label": "Shutter is open."}, {})
assert pill._state_name == "shutter_open"
assert pill._name_label.text() == "shutter_open"
assert pill._status_label.text() == "VALID"
assert pill._detail_label.text() == "Shutter is open."
assert not pill._icon_label.pixmap().isNull()
assert pill.toolTip() == "Shutter is open."
def test_beamline_state_pill_ignores_other_states(qtbot, mocked_client):
pill = create_widget(qtbot, BeamlineStatePill, state_name="shutter_open", client=mocked_client)
pill.update_state(
{"name": "other_state", "status": "invalid", "label": "Should be ignored."}, {}
)
assert pill._status_label.text() == "UNKNOWN"
assert pill.toolTip() == "No state information available."
def test_beamline_state_pill_expands_and_emits_updated_limits(qtbot, mocked_client):
limits_pill = create_widget(qtbot, BeamlineStatePill, state_name="limits", client=mocked_client)
limits_pill.set_state_config(_limits_state())
assert limits_pill._settings.isHidden()
assert limits_pill._config_form is None
assert not limits_pill._update_button.isEnabled()
assert not limits_pill._revert_button.isEnabled()
qtbot.mouseClick(limits_pill._header, Qt.MouseButton.LeftButton)
assert limits_pill._config_form is not None
high_limit = limits_pill._config_form.input_widget("high_limit")
high_limit.setValue(20.0)
assert not limits_pill._settings.isHidden()
assert limits_pill._update_button.isEnabled()
assert limits_pill._revert_button.isEnabled()
assert (
limits_pill._config_form.field_widget("high_limit").property("beamlineStateDirty") is True
)
assert limits_pill._config_form.get_data()["device"] == "samx"
assert limits_pill.edited_config().high_limit == 20.0
with qtbot.waitSignal(limits_pill.update_requested) as signal:
limits_pill._update_button.click()
assert signal.args[0] == "limits"
assert isinstance(signal.args[1], bl_states.DeviceWithinLimitsState.CONFIG_CLASS)
assert signal.args[1].device == "samx"
assert signal.args[1].signal == "samx"
assert signal.args[1].low_limit == 0.0
assert signal.args[1].high_limit == 20.0
assert signal.args[1].tolerance == 0.1
assert not limits_pill._settings.isHidden()
def test_beamline_state_pill_first_expand_uses_config_class_without_rebuild(
qtbot, mocked_client, monkeypatch
):
limits_pill = create_widget(qtbot, BeamlineStatePill, state_name="limits", client=mocked_client)
set_model_calls = []
original_set_model = pill_module.PydanticWidgetForm.set_model
def set_model_spy(self, model, data=None):
set_model_calls.append(model)
return original_set_model(self, model, data=data)
monkeypatch.setattr(pill_module.PydanticWidgetForm, "set_model", set_model_spy)
limits_pill.set_state_config(_limits_state())
limits_pill.set_expanded(True)
assert limits_pill._config_form is not None
assert set_model_calls == []
def test_beamline_state_pill_reverts_changed_settings(qtbot, mocked_client):
limits_pill = create_widget(qtbot, BeamlineStatePill, state_name="limits", client=mocked_client)
limits_pill.set_state_config(_limits_state())
limits_pill.set_expanded(True)
assert limits_pill._config_form is not None
low_limit = limits_pill._config_form.input_widget("low_limit")
low_limit.setValue(-5.0)
assert limits_pill._update_button.isEnabled()
assert limits_pill._config_form.field_widget("low_limit").property("beamlineStateDirty") is True
limits_pill._revert_button.click()
assert low_limit.value() == 0.0
assert not limits_pill._update_button.isEnabled()
assert not limits_pill._revert_button.isEnabled()
assert (
limits_pill._config_form.field_widget("low_limit").property("beamlineStateDirty") is False
)
def test_beamline_state_pill_releases_form_on_collapse(qtbot, mocked_client):
limits_pill = create_widget(qtbot, BeamlineStatePill, state_name="limits", client=mocked_client)
limits_pill.set_state_config(_limits_state())
limits_pill.set_expanded(True)
device_widget = limits_pill._config_form.input_widget("device")
limits_pill.set_expanded(False)
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
assert limits_pill._config_form is None
assert not shiboken6.isValid(device_widget)
limits_pill.set_expanded(True)
assert limits_pill._config_form is not None
assert limits_pill._config_form.input_widget("high_limit").value() == 10.0
assert not limits_pill._update_button.isEnabled()
def test_beamline_state_pill_collapse_discards_unsaved_edits(qtbot, mocked_client):
limits_pill = create_widget(qtbot, BeamlineStatePill, state_name="limits", client=mocked_client)
limits_pill.set_state_config(_limits_state())
limits_pill.set_expanded(True)
limits_pill._config_form.input_widget("high_limit").setValue(20.0)
assert limits_pill._update_button.isEnabled()
limits_pill.set_expanded(False)
assert limits_pill._config_form is None
limits_pill.set_expanded(True)
assert limits_pill._config_form.input_widget("high_limit").value() == 10.0
assert not limits_pill._update_button.isEnabled()
def test_beamline_state_pill_does_not_override_themed_input_controls(qtbot, mocked_client):
limits_pill = create_widget(qtbot, BeamlineStatePill, state_name="limits", client=mocked_client)
limits_pill.set_state_config(_limits_state())
limits_pill.set_expanded(True)
stylesheet = limits_pill.styleSheet()
assert "QAbstractSpinBox" not in stylesheet
assert "QComboBox" not in stylesheet
assert "QCheckBox::indicator" not in stylesheet
def test_beamline_state_manager_adds_and_removes_pills(qtbot, mocked_client):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
beamline_state_manager.update_available_states(
{
"states": [
_state("shutter_open", "ShutterState"),
_state("limits", "DeviceWithinLimitsState"),
]
},
{},
)
assert sorted(beamline_state_manager._state_pills) == ["limits", "shutter_open"]
assert beamline_state_manager._model.rowCount() == 2
assert beamline_state_manager._state_pills["shutter_open"]._name_label.text() == "shutter_open"
assert not beamline_state_manager._empty_label.isVisible()
beamline_state_manager._state_pills["limits"].update_state(
{"name": "limits", "status": "valid", "label": "Within limits."}, {}
)
summary = beamline_state_manager.state_summary()
assert summary["limits"] == {"status": "valid", "label": "Within limits."}
assert summary["shutter_open"]["status"] == "unknown"
beamline_state_manager.update_available_states(
{"states": [_state("limits", "DeviceWithinLimitsState")]}, {}
)
assert sorted(beamline_state_manager._state_pills) == ["limits"]
assert beamline_state_manager._model.rowCount() == 1
def test_beamline_state_manager_ignores_unchanged_available_states(qtbot, mocked_client):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
content = {"states": [_limits_state()]}
beamline_state_manager.update_available_states(content, {})
pill = beamline_state_manager._state_pills["limits"]
beamline_state_manager.update_available_states(content, {})
assert beamline_state_manager._state_pills["limits"] is pill
assert pill._config_form is None
def test_beamline_state_manager_adds_state_without_recreating_existing_pills(qtbot, mocked_client):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
limits_state = _limits_state()
shutter_state = _state("shutter_open", "ShutterState")
beamline_state_manager.update_available_states({"states": [limits_state]}, {})
pill = beamline_state_manager._state_pills["limits"]
pill.set_expanded(True)
config_form = pill._config_form
beamline_state_manager.update_available_states({"states": [limits_state, shutter_state]}, {})
assert beamline_state_manager._state_pills["limits"] is pill
assert pill._config_form is config_form
assert pill.is_expanded()
assert sorted(beamline_state_manager._state_pills) == ["limits", "shutter_open"]
def test_beamline_state_manager_header_click_expands_pill_once(qtbot, mocked_client):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
beamline_state_manager.update_available_states(
{"states": [_state("limits", "DeviceWithinLimitsState", {"device": "samx"})]}, {}
)
pill = beamline_state_manager._state_pills["limits"]
assert pill._settings.isHidden()
qtbot.mouseClick(pill._header, Qt.MouseButton.LeftButton)
assert not pill._settings.isHidden()
def test_beamline_state_manager_preserves_expanded_pill_on_refresh(qtbot, mocked_client):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
state = _state("limits", "DeviceWithinLimitsState", {"device": "samx", "high_limit": 10.0})
beamline_state_manager.update_available_states({"states": [state]}, {})
beamline_state_manager._state_pills["limits"].set_expanded(True)
beamline_state_manager.update_available_states({"states": [state]}, {})
assert beamline_state_manager._state_pills["limits"].is_expanded()
assert not beamline_state_manager._state_pills["limits"]._settings.isHidden()
def test_beamline_state_manager_propagates_idle_card_background(qtbot, mocked_client):
idle_card_manager = create_widget(
qtbot, BeamlineStateManager, client=mocked_client, idle_card_background=True
)
idle_card_manager.update_available_states(
{"states": [_state("limits", "DeviceWithinLimitsState", {"device": "samx"})]}, {}
)
assert idle_card_manager._state_pills["limits"]._idle_card_background is True
idle_card_manager.idle_card_background = False
assert idle_card_manager._state_pills["limits"]._idle_card_background is False
def test_beamline_state_manager_filters_status(qtbot, mocked_client):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
beamline_state_manager.update_available_states(
{"states": [_shutter_state(), _limits_state()]}, {}
)
assert isinstance(beamline_state_manager._toolbar, ModularToolBar)
assert not beamline_state_manager._toolbar.components.exists("refresh")
beamline_state_manager._state_pills["limits"].update_state(
{"name": "limits", "status": "valid", "label": "Within limits."}, {}
)
beamline_state_manager._state_pills["shutter_open"].update_state(
{"name": "shutter_open", "status": "invalid", "label": "Closed."}, {}
)
beamline_state_manager._selected_statuses = {"valid"}
beamline_state_manager._apply_filters()
assert not beamline_state_manager._hidden_summary.isHidden()
assert "1 state is hidden" in beamline_state_manager._hidden_summary.text()
assert not beamline_state_manager._view.isRowHidden(
beamline_state_manager._model.index_for_name("limits").row()
)
assert beamline_state_manager._view.isRowHidden(
beamline_state_manager._model.index_for_name("shutter_open").row()
)
beamline_state_manager._hidden_summary.click()
assert not beamline_state_manager._view.isRowHidden(
beamline_state_manager._model.index_for_name("shutter_open").row()
)
assert shiboken6.isValid(beamline_state_manager._state_pills["shutter_open"])
beamline_state_manager._hidden_summary.click()
assert beamline_state_manager._view.isRowHidden(
beamline_state_manager._model.index_for_name("shutter_open").row()
)
assert shiboken6.isValid(beamline_state_manager._state_pills["shutter_open"])
def test_beamline_state_manager_status_filter_reacts_to_state_changes(qtbot, mocked_client):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
beamline_state_manager.update_available_states(
{"states": [_state("limits", "DeviceWithinLimitsState", {"device": "samx"})]}, {}
)
beamline_state_manager._selected_statuses = {"valid"}
beamline_state_manager._state_pills["limits"].update_state(
{"name": "limits", "status": "valid", "label": "Within limits."}, {}
)
assert beamline_state_manager._hidden_summary.isHidden()
beamline_state_manager._state_pills["limits"].update_state(
{"name": "limits", "status": "invalid", "label": "Out of limits."}, {}
)
assert not beamline_state_manager._hidden_summary.isHidden()
assert beamline_state_manager._view.isRowHidden(
beamline_state_manager._model.index_for_name("limits").row()
)
def test_beamline_state_manager_filters_devices(qtbot, mocked_client, monkeypatch):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
beamline_state_manager.update_available_states(
{
"states": [
_limits_state(name="samx_limits"),
_limits_state(name="samy_limits", device="samy"),
]
},
{},
)
beamline_state_manager._device_filter_text = "samx"
beamline_state_manager._apply_filters()
assert not beamline_state_manager._hidden_summary.isHidden()
assert "1 state is hidden" in beamline_state_manager._hidden_summary.text()
captured = {}
class FakeDeviceFilterDialog:
def __init__(self, devices, selected_devices, device_filter_text, parent):
captured["devices"] = devices
captured["selected_devices"] = selected_devices
captured["device_filter_text"] = device_filter_text
captured["parent"] = parent
def exec(self):
return 0
monkeypatch.setattr(manager_module, "DeviceFilterDialog", FakeDeviceFilterDialog)
beamline_state_manager.open_device_filter_dialog()
assert captured["devices"] == ["samx", "samy"]
assert captured["device_filter_text"] == "samx"
assert captured["parent"] is beamline_state_manager
def test_beamline_state_manager_collapse_all(qtbot, mocked_client):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
beamline_state_manager.update_available_states(
{"states": [_limits_state(), _state("shutter_open", "ShutterState", {"device": "samy"})]},
{},
)
for pill in beamline_state_manager._state_pills.values():
pill.set_expanded(True)
assert all(pill.is_expanded() for pill in beamline_state_manager._state_pills.values())
collapse_action = beamline_state_manager._toolbar.components.get_action("collapse_all")
collapse_action.action.trigger()
assert not any(pill.is_expanded() for pill in beamline_state_manager._state_pills.values())
def test_beamline_state_manager_backend_echo_repopulates_expanded_pill(qtbot, mocked_client):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
beamline_state_manager.update_available_states({"states": [_limits_state()]}, {})
pill = beamline_state_manager._state_pills["limits"]
pill.set_expanded(True)
high_limit = pill._config_form.input_widget("high_limit")
high_limit.setValue(20.0)
assert pill._update_button.isEnabled()
beamline_state_manager.update_available_states({"states": [_limits_state(high_limit=20.0)]}, {})
assert pill.is_expanded()
assert high_limit.value() == 20.0
assert not pill._update_button.isEnabled()
assert pill._config_form.dirty_fields() == set()
def test_beamline_state_manager_updates_state_parameters(qtbot, mocked_client):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
beamline_state_manager.update_available_states({"states": [_limits_state()]}, {})
class StateClient:
def __init__(self):
self.parameters = None
def update_parameters(self, **kwargs):
self.parameters = kwargs
class StateManager:
def __init__(self):
self.limits = StateClient()
mocked_client.beamline_states = StateManager()
pill = beamline_state_manager._state_pills["limits"]
pill.set_expanded(True)
high_limit = pill._config_form.input_widget("high_limit")
high_limit.setValue(20.0)
assert pill._update_button.isEnabled()
beamline_state_manager._update_state_parameters("limits", pill.edited_config())
assert mocked_client.beamline_states.limits.parameters == {
"device": "samx",
"signal": "samx",
"low_limit": 0.0,
"high_limit": 20.0,
"tolerance": 0.1,
}
assert not pill._update_button.isEnabled()
assert pill._config_form.field_widget("high_limit").property("beamlineStateDirty") is False
def test_beamline_state_manager_removes_state(qtbot, mocked_client, monkeypatch):
beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client)
class StateManager:
def __init__(self):
self.deleted = None
def delete(self, state_name):
self.deleted = state_name
mocked_client.beamline_states = StateManager()
monkeypatch.setattr(
QMessageBox, "question", lambda *args, **kwargs: QMessageBox.StandardButton.Yes
)
beamline_state_manager._remove_state_requested("limits")
assert mocked_client.beamline_states.deleted == "limits"
def test_add_beamline_state_dialog_uses_generated_widgets_and_normalizes_name(qtbot, mocked_client):
add_state_dialog = create_widget(qtbot, AddBeamlineStateDialog, client=mocked_client)
limits_index = add_state_dialog._type_combo.findText(bl_states.DeviceWithinLimitsState.__name__)
assert limits_index >= 0
add_state_dialog._type_combo.setCurrentIndex(limits_index)
assert add_state_dialog._config_form.model is bl_states.DeviceWithinLimitsState.CONFIG_CLASS
name = add_state_dialog._config_form.input_widget("name")
device = add_state_dialog._config_form.input_widget("device")
signal = add_state_dialog._config_form.input_widget("signal")
low_limit = add_state_dialog._config_form.field_widget("low_limit")
high_limit = add_state_dialog._config_form.field_widget("high_limit")
name.setText("samx-limits")
WidgetIO.set_value(device, "samx")
WidgetIO.set_value(signal, "samx")
low_limit.checkbox.setChecked(True)
high_limit.checkbox.setChecked(True)
high_limit.value_widget.setValue(15.0)
config = add_state_dialog.config()
assert config.name == "samx_limits"
assert config.device == "samx"
assert config.signal == "samx"
assert config.low_limit == 0.0
assert config.high_limit == 15.0
def test_add_beamline_state_dialog_generates_name_only_after_valid_device_selection(
qtbot, mocked_client
):
add_state_dialog = create_widget(qtbot, AddBeamlineStateDialog, client=mocked_client)
name = add_state_dialog._config_form.input_widget("name")
device = add_state_dialog._config_form.input_widget("device")
device.setCurrentText("s")
assert name.text() == ""
device.set_device("samx")
assert name.text() == "samx_device_within_limits_state"
def test_add_beamline_state_dialog_switches_state_type_without_collapsing(qtbot, mocked_client):
add_state_dialog = create_widget(qtbot, AddBeamlineStateDialog, client=mocked_client)
initial_height = add_state_dialog.height()
limits_index = add_state_dialog._type_combo.findText("DeviceWithinLimitsState")
assert limits_index >= 0
shutter_index = add_state_dialog._type_combo.findText("ShutterState")
assert shutter_index >= 0
add_state_dialog._type_combo.setCurrentIndex(shutter_index)
qtbot.wait(0)
assert add_state_dialog._config_form.model is bl_states.DeviceStateConfig
assert add_state_dialog._config_form_host.count() == 1
assert not add_state_dialog._config_form.isHidden()
assert not add_state_dialog._buttons.isHidden()
assert add_state_dialog.sizeHint().height() > add_state_dialog._buttons.sizeHint().height()
assert add_state_dialog.minimumHeight() == add_state_dialog.maximumHeight()
add_state_dialog._type_combo.setCurrentIndex(limits_index)
qtbot.wait(0)
assert add_state_dialog._config_form.model is bl_states.DeviceWithinLimitsState.CONFIG_CLASS
assert add_state_dialog.height() >= initial_height
assert add_state_dialog.minimumHeight() == add_state_dialog.maximumHeight()
def test_add_beamline_state_dialog_cleanup_deletes_device_widgets(qtbot, mocked_client):
add_state_dialog = create_widget(qtbot, AddBeamlineStateDialog, client=mocked_client)
device = add_state_dialog._config_form.input_widget("device")
signal = add_state_dialog._config_form.input_widget("signal")
add_state_dialog.reject()
assert shiboken6.isValid(device)
assert shiboken6.isValid(signal)
add_state_dialog.cleanup()
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
assert not shiboken6.isValid(device)
assert not shiboken6.isValid(signal)
-26
View File
@@ -6,7 +6,6 @@ from qtpy.QtCore import QObject
from qtpy.QtWidgets import QApplication, QWidget
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty
from bec_widgets.utils.error_popups import SafeSlot as Slot
@@ -16,9 +15,6 @@ from .client_mocks import mocked_client
class BECConnectorQObject(BECConnector, QObject): ...
class _CleanupBroadcastWidget(BECWidget, QWidget): ...
@pytest.fixture
def bec_connector(mocked_client):
connector = BECConnectorQObject(client=mocked_client)
@@ -150,28 +146,6 @@ def test_bec_connector_change_object_name(bec_connector):
assert not any(obj.objectName() == previous_name for obj in all_objects)
def test_bec_widget_cleanup_broadcasts_after_children_are_unregistered(mocked_client, qtbot):
parent = _CleanupBroadcastWidget(client=mocked_client, object_name="cleanup_parent")
child = _CleanupBroadcastWidget(
parent=parent, client=mocked_client, object_name="cleanup_child"
)
qtbot.addWidget(parent)
observed_connections = []
parent.rpc_register.callbacks.append(
lambda connections: observed_connections.append(set(connections))
)
parent.close()
assert parent._destroyed is True
assert child.gui_id not in parent.rpc_register.list_all_connections()
assert all(
parent.gui_id in snapshot or child.gui_id not in snapshot
for snapshot in observed_connections
)
def test_bec_connector_export_settings():
class MyWidget(BECConnector, QWidget):
+1 -47
View File
@@ -5,7 +5,7 @@ from unittest import mock
import pytest
from bec_lib import service_config
from bec_lib.messages import GUIInstructionMessage, ScanMessage
from bec_lib.messages import ScanMessage
from bec_lib.serialization import MsgpackSerialization
from bec_widgets.utils.bec_dispatcher import BECDispatcher, QtRedisConnector, QtThreadSafeCallback
@@ -213,49 +213,3 @@ def test_dispatcher_2_topic_same_cb_with_boundmethod(
send_msg_event.set()
qtbot.wait(10)
def test_qt_redis_connector_logs_rpc_before_qt_callback(monkeypatch):
info_mock = mock.MagicMock()
warning_mock = mock.MagicMock()
monkeypatch.setattr("bec_widgets.utils.bec_dispatcher.logger.info", info_mock)
monkeypatch.setattr("bec_widgets.utils.bec_dispatcher.logger.warning", warning_mock)
def callback(_msg, _metadata):
pass
cb = QtThreadSafeCallback(callback)
connector = QtRedisConnector("localhost:1", mock.MagicMock())
rpc_msg = GUIInstructionMessage(
action="set_value",
parameter={"args": [1], "kwargs": {"source": "test"}, "gui_id": "ring"},
metadata={
"request_id": "dispatcher-request",
"receiver": "gui",
"object_name": "progressbar",
"timeout": 0.1,
"sent_at": 1.0,
"deadline": 1.1,
},
)
try:
connector._execute_callback(cb, {"data": rpc_msg}, {})
info_mock.assert_called_once()
info_message = info_mock.call_args.args[0]
assert "GUI RPC dispatcher received request before Qt callback emit" in info_message
assert "request_id=dispatcher-request" in info_message
assert "method=set_value" in info_message
assert "receiver=gui" in info_message
assert "target_gui_id=ring" in info_message
assert "object_name=progressbar" in info_message
assert "timeout=0.1" in info_message
assert "stale_on_dispatch=True" in info_message
warning_mock.assert_called_once()
warning_message = warning_mock.call_args.args[0]
assert "received request after client timeout deadline" in warning_message
assert "request_id=dispatcher-request" in warning_message
finally:
connector.shutdown()
+20 -176
View File
@@ -1,8 +1,5 @@
from unittest import mock
import numpy as np
import pytest
from qtpy.QtGui import QPalette
from qtpy.QtWidgets import QProgressBar
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import (
BECProgressBar,
@@ -18,14 +15,6 @@ def progressbar(qtbot):
yield widget
@pytest.fixture
def static_progressbar(qtbot):
widget = BECProgressBar(enable_dynamic_stylesheet=False)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_progressbar(progressbar):
progressbar.update()
@@ -34,181 +23,36 @@ def test_progressbar_set_value(qtbot, progressbar):
progressbar.set_minimum(0)
progressbar.set_maximum(100)
progressbar.set_value(50)
progressbar.paintEvent(None)
assert isinstance(progressbar.progressbar, QProgressBar)
assert progressbar._value == progressbar._user_value * progressbar._oversampling_factor
assert progressbar.progressbar.value() == 50 * progressbar._oversampling_factor
qtbot.waitUntil(
lambda: np.isclose(
progressbar._value, progressbar._user_value * progressbar._oversampling_factor
)
)
def test_progressbar_label(progressbar):
progressbar.label_template = "Test: $value"
progressbar.set_value(50)
assert progressbar._get_label() == "Test: 50"
assert progressbar.progressbar.text() == "Test: 50"
assert progressbar.center_label.text() == "Test: 50"
def test_progressbar_equal_minimum_and_maximum_does_not_raise(progressbar):
progressbar.set_minimum(0)
progressbar.set_maximum(0)
progressbar.set_value(0)
assert progressbar._get_label() == "0 / 0 - 100 %"
assert progressbar.progressbar.value() == progressbar.progressbar.maximum()
def test_progressbar_uses_static_stylesheet_with_palette_state_color(progressbar):
progressbar.progressbar.resize(100, 20)
progressbar.set_value(50)
progressbar.state = ProgressState.PAUSED
style_sheet = progressbar.progressbar.styleSheet()
assert "QProgressBar::chunk" in style_sheet
assert (
f"background-color: {progressbar._state_colors[ProgressState.PAUSED].name()};"
in style_sheet
)
assert "background-color: palette(mid);" in style_sheet
assert "border-radius: 7px;" in style_sheet
assert (
progressbar.progressbar.palette().color(QPalette.ColorRole.Highlight)
== progressbar._state_colors[ProgressState.PAUSED]
)
def test_progressbar_value_updates_do_not_rebuild_stylesheet_within_same_chunk_mode(progressbar):
progressbar.progressbar.resize(100, 20)
progressbar.set_value(30)
with mock.patch.object(
progressbar, "_setup_style_sheet", wraps=progressbar._setup_style_sheet
) as setup_style_sheet:
progressbar.set_value(35)
progressbar.set_value(42)
progressbar.set_value(50)
setup_style_sheet.assert_not_called()
def test_progressbar_value_updates_skip_chunk_radius_after_target_reached(progressbar):
progressbar.progressbar.resize(100, 20)
progressbar.set_value(30)
assert progressbar._chunk_radius == progressbar._target_chunk_radius()
with mock.patch.object(
progressbar, "_update_chunk_radius", wraps=progressbar._update_chunk_radius
) as update_chunk_radius:
progressbar.set_value(35)
progressbar.set_value(42)
progressbar.set_value(50)
update_chunk_radius.assert_not_called()
def test_progressbar_repeated_same_maximum_does_not_reset_chunk_radius(progressbar):
progressbar.progressbar.resize(100, 20)
progressbar.set_maximum(100)
progressbar.set_value(30)
assert progressbar._chunk_radius == progressbar._target_chunk_radius()
with mock.patch.object(
progressbar, "_update_chunk_radius", wraps=progressbar._update_chunk_radius
) as update_chunk_radius:
progressbar.set_maximum(100)
progressbar.set_value(40)
update_chunk_radius.assert_not_called()
def test_progressbar_can_disable_dynamic_stylesheet(static_progressbar):
static_progressbar.progressbar.resize(100, 20)
assert static_progressbar.enable_dynamic_stylesheet is False
assert static_progressbar._chunk_radius == static_progressbar._target_chunk_radius()
with mock.patch.object(
static_progressbar, "_setup_style_sheet", wraps=static_progressbar._setup_style_sheet
) as setup_style_sheet:
static_progressbar.set_value(1)
static_progressbar.set_value(2)
static_progressbar.set_value(3)
setup_style_sheet.assert_not_called()
assert "border-radius: 7px;" in static_progressbar.progressbar.styleSheet()
def test_progressbar_dynamic_stylesheet_can_be_toggled(progressbar):
progressbar.enable_dynamic_stylesheet = False
assert progressbar.enable_dynamic_stylesheet is False
assert progressbar._chunk_radius == progressbar._target_chunk_radius()
assert "border-radius: 7px;" in progressbar.progressbar.styleSheet()
def test_progressbar_rebuilds_stylesheet_until_chunk_radius_reaches_target(progressbar):
progressbar.progressbar.resize(100, 20)
progressbar.set_value(9)
with mock.patch.object(
progressbar, "_setup_style_sheet", wraps=progressbar._setup_style_sheet
) as setup_style_sheet:
progressbar.set_value(12)
progressbar.set_value(25)
progressbar.set_value(30)
assert setup_style_sheet.call_count == 2
assert "border-radius: 7px;" in progressbar.progressbar.styleSheet()
def test_progressbar_resets_chunk_radius_when_value_goes_backwards(progressbar):
progressbar.progressbar.resize(100, 20)
progressbar.set_value(30)
assert "border-radius: 7px;" in progressbar.progressbar.styleSheet()
progressbar.set_value(4)
assert "border-radius: 2px;" in progressbar.progressbar.styleSheet()
def test_progress_state_from_bec_status():
"""ProgressState.from_bec_status() maps BEC literals correctly."""
mapping = {
"open": ProgressState.NORMAL,
"paused": ProgressState.PAUSED,
"aborted": ProgressState.INTERRUPTED,
"halted": ProgressState.PAUSED,
"closed": ProgressState.COMPLETED,
"UNKNOWN": ProgressState.NORMAL, # fallback
}
for text, expected in mapping.items():
assert ProgressState.from_bec_status(text) is expected
def test_progressbar_state_setter(progressbar):
"""Setting .state reflects internally."""
progressbar.state = ProgressState.PAUSED
assert progressbar.state is ProgressState.PAUSED
def test_progressbar_warning_state_has_own_color_and_persists_on_value_update(progressbar):
assert (
progressbar._state_colors[ProgressState.PAUSED]
!= progressbar._state_colors[ProgressState.WARNING]
)
assert (
progressbar._state_colors[ProgressState.WARNING]
!= progressbar._state_colors[ProgressState.INTERRUPTED]
)
progressbar.state = ProgressState.WARNING
progressbar.set_value(50)
assert progressbar.state is ProgressState.WARNING
assert (
progressbar.progressbar.palette().color(QPalette.ColorRole.Highlight)
== progressbar._state_colors[ProgressState.WARNING]
)
def test_progressbar_warning_state_has_own_color_and_persists_on_value_update(progressbar):
assert (
progressbar._state_colors[ProgressState.PAUSED]
!= progressbar._state_colors[ProgressState.WARNING]
)
assert (
progressbar._state_colors[ProgressState.WARNING]
!= progressbar._state_colors[ProgressState.INTERRUPTED]
)
progressbar.state = ProgressState.WARNING
progressbar.set_value(50)
assert progressbar.state is ProgressState.WARNING
assert (
progressbar.progressbar.palette().color(QPalette.ColorRole.Highlight)
== progressbar._state_colors[ProgressState.WARNING]
)
+2 -117
View File
@@ -1,19 +1,10 @@
import signal
import subprocess
from contextlib import contextmanager
from unittest import mock
import pytest
from bec_widgets.cli.client import BECDockArea
from bec_widgets.cli.client_utils import (
GRACEFUL_SERVER_SHUTDOWN_RPC_TIMEOUT,
OUTPUT_READER_STOP_EVENT_ATTR,
BECGuiClient,
_join_process_output_thread,
_start_plot_process,
)
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCResponseTimeoutError, rpc_timeout
from bec_widgets.cli.client_utils import BECGuiClient, _start_plot_process
@pytest.fixture
@@ -229,7 +220,7 @@ def test_client_utils_new_starts_server_when_not_alive():
with mock.patch("bec_widgets.cli.client_utils.wait_for_server", _no_wait_for_server):
with (
mock.patch.object(gui, "_check_if_server_is_alive", return_value=False),
mock.patch.object(gui, "show") as mock_start,
mock.patch.object(gui, "start") as mock_start,
):
gui.new(wait=False, startup_profile=None)
@@ -266,109 +257,3 @@ def test_client_utils_delete_falls_back_to_direct_close():
gui.delete("dock")
widget._run_rpc.assert_called_once_with("close")
def test_client_utils_gui_client_set_rpc_timeout():
gui = BECGuiClient()
assert gui._rpc_timeout == 60
gui.set_rpc_timeout(10)
assert gui._rpc_timeout == 10
def test_client_utils_kill_server_waits_for_process_before_joining_output_thread():
gui = BECGuiClient()
gui._client = mock.MagicMock()
gui._process = mock.MagicMock(pid=123, stdout=None, stderr=None)
gui._process.poll.return_value = None
order = []
gui._process.wait.side_effect = lambda timeout: order.append("wait")
gui._process_output_processing_thread = mock.MagicMock()
gui._process_output_processing_thread.join.side_effect = lambda timeout: order.append("join")
gui._process_output_processing_thread.is_alive.return_value = False
with (
mock.patch.object(gui, "_request_server_shutdown", return_value=False),
mock.patch("bec_widgets.cli.client_utils.os.getpgid", return_value=123),
mock.patch("bec_widgets.cli.client_utils.os.killpg") as killpg,
):
gui.kill_server()
killpg.assert_called_once_with(123, signal.SIGTERM)
assert order == ["wait", "join"]
assert gui._process is None
assert gui._process_output_processing_thread is None
def test_client_utils_kill_server_requests_graceful_shutdown_before_signal():
gui = BECGuiClient()
gui._client = mock.MagicMock()
process = mock.MagicMock(stdout=None, stderr=None)
process.poll.return_value = None
gui._process = process
gui._process_output_processing_thread = mock.MagicMock()
gui._process_output_processing_thread.is_alive.return_value = False
launcher = mock.MagicMock()
with (
mock.patch.object(
BECGuiClient, "launcher", new_callable=mock.PropertyMock
) as launcher_prop,
mock.patch("bec_widgets.cli.client_utils.os.killpg") as killpg,
):
launcher_prop.return_value = launcher
gui.kill_server()
launcher._run_rpc.assert_called_once_with(
"system.shutdown", wait_for_rpc_response=True, timeout=GRACEFUL_SERVER_SHUTDOWN_RPC_TIMEOUT
)
process.wait.assert_called_once_with(timeout=5)
killpg.assert_not_called()
assert gui._process is None
assert gui._process_output_processing_thread is None
def test_client_utils_kill_server_kills_process_group_after_timeout():
gui = BECGuiClient()
gui._client = mock.MagicMock()
process = mock.MagicMock(pid=123, stdout=None, stderr=None, args=["bec-gui-server"])
process.poll.return_value = None
process.wait.side_effect = [subprocess.TimeoutExpired(cmd="bec-gui-server", timeout=10), None]
gui._process = process
with (
mock.patch.object(gui, "_request_server_shutdown", return_value=False),
mock.patch("bec_widgets.cli.client_utils.os.getpgid", return_value=123),
mock.patch("bec_widgets.cli.client_utils.os.killpg") as killpg,
mock.patch("bec_widgets.cli.client_utils.subprocess.run") as run,
):
run.return_value.stdout = "PID PPID PGID STAT COMMAND\n123 1 123 S bec-gui-server"
gui.kill_server()
assert killpg.call_args_list == [mock.call(123, signal.SIGTERM), mock.call(123, signal.SIGKILL)]
assert process.wait.call_args_list == [mock.call(timeout=10), mock.call(timeout=10)]
run.assert_called_once_with(
["ps", "-o", "pid,ppid,pgid,stat,command", "-g", "123"],
check=False,
capture_output=True,
text=True,
timeout=2,
)
def test_join_process_output_thread_signals_reader_before_closing_streams():
process = mock.MagicMock(pid=123, args=["bec-gui-server"])
process.stdout = mock.MagicMock()
process.stderr = mock.MagicMock()
thread = mock.MagicMock()
stop_event = mock.MagicMock()
setattr(thread, OUTPUT_READER_STOP_EVENT_ATTR, stop_event)
thread.is_alive.side_effect = [True, False]
logger = mock.MagicMock()
_join_process_output_thread(process, thread, logger)
assert thread.join.call_args_list == [mock.call(timeout=2), mock.call(timeout=2)]
stop_event.set.assert_called_once_with()
process.stdout.close.assert_called_once_with()
process.stderr.close.assert_called_once_with()
+6 -22
View File
@@ -2,11 +2,11 @@ import pyqtgraph as pg
import pytest
from pydantic import ValidationError
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import Colors, apply_theme, get_theme_name, rgba, theme_color
from bec_widgets.utils.colors import Colors, apply_theme
from bec_widgets.widgets.plots.waveform.curve import CurveConfig
from tests.unit_tests.client_mocks import mocked_client
from tests.unit_tests.conftest import create_widget
@@ -76,26 +76,10 @@ def test_hex_to_rgba():
Colors.hex_to_rgba("#FF573")
def test_get_theme_name_uses_application_theme():
app = QApplication.instance()
assert app.theme.theme == "light"
assert get_theme_name() == "light"
def test_theme_color_uses_theme_color_method():
app = QApplication.instance()
fallback = QColor("#ffffff")
expected = app.theme.color("FG", fallback.name())
assert theme_color(app.theme, "FG", fallback).name() == expected.name()
def test_theme_color_returns_fallback_without_theme():
assert theme_color(None, "FG", QColor("#ffffff")).name() == "#ffffff"
def test_qss_rgba_helper():
assert rgba(QColor("#010203"), 255) == "rgba(1, 2, 3, 255)"
def test_rgba_to_hex():
assert Colors.rgba_to_hex(255, 87, 51, 255) == "#FF5733FF"
assert Colors.rgba_to_hex(255, 87, 51, 128) == "#FF573380"
assert Colors.rgba_to_hex(255, 87, 51) == "#FF5733FF"
def test_canonical_colormap_name_case_insensitive():
@@ -98,15 +98,11 @@ def test_waiting_display(update_dialog, qtbot):
mock_spinner_stop.assert_called_once()
def test_update_cycle(update_dialog):
def test_update_cycle(update_dialog, qtbot):
update = {"enabled": False, "readoutPriority": "baseline", "deviceTags": {"tag"}}
def _mock_send(action="update", config=None, wait_for_response=True, timeout_s=None):
device = update_dialog.client.device_manager.devices["test_device"]
device._config = {**device._config, **config["test_device"]} # type: ignore
update_dialog._q_threadpool = MagicMock()
update_dialog._q_threadpool.start.side_effect = lambda runnable: runnable.run()
update_dialog.client.device_manager.devices["test_device"]._config = config["test_device"] # type: ignore
update_dialog._config_helper.send_config_request = MagicMock(side_effect=_mock_send)
for item in update_dialog._form.enumerate_form_widgets():
@@ -115,7 +111,9 @@ def test_update_cycle(update_dialog):
assert update_dialog.updated_config() == update
update_dialog.apply()
update_dialog._q_threadpool.start.assert_called_once()
qtbot.waitUntil(
lambda: update_dialog._config_helper.send_config_request.call_count == 1, timeout=100
)
update_dialog._config_helper.send_config_request.assert_called_with(
action="update", config={"test_device": update}, wait_for_response=False
@@ -40,7 +40,7 @@ def test_update_device_initialization_progress(progress_bar, qtbot):
assert progress_bar.progress_bar._user_value == 1
assert progress_bar.progress_bar._user_maximum == 3
assert progress_bar.progress_label.text() == f"{msg.device} initialization in progress..."
assert "1 / 3 - 33 %" == progress_bar.progress_bar.progressbar.text()
assert "1 / 3 - 33 %" == progress_bar.progress_bar.center_label.text()
# II. Update with message of finished DeviceInitializationProgressMessage, finished=True, success=True
msg.finished = True
@@ -49,7 +49,7 @@ def test_update_device_initialization_progress(progress_bar, qtbot):
assert progress_bar.progress_bar._user_value == 1
assert progress_bar.progress_bar._user_maximum == 3
assert progress_bar.progress_label.text() == f"{msg.device} initialization succeeded!"
assert "1 / 3 - 33 %" == progress_bar.progress_bar.progressbar.text()
assert "1 / 3 - 33 %" == progress_bar.progress_bar.center_label.text()
# III. Update with message of finished DeviceInitializationProgressMessage, finished=True, success=False
msg.finished = True
@@ -59,7 +59,7 @@ def test_update_device_initialization_progress(progress_bar, qtbot):
with qtbot.waitSignal(progress_bar.failed_devices_changed) as signal_blocker:
progress_bar._update_device_initialization_progress(msg.model_dump(), {})
assert progress_bar.progress_label.text() == f"{msg.device} initialization failed!"
assert "2 / 3 - 66 %" == progress_bar.progress_bar.progressbar.text()
assert "2 / 3 - 66 %" == progress_bar.progress_bar.center_label.text()
assert progress_bar.progress_bar._user_value == 2
assert progress_bar.progress_bar._user_maximum == 3
+1 -18
View File
@@ -120,7 +120,7 @@ def test_device_input_combobox_disabled_invalid_has_neutral_border(device_input_
assert "red" in device_input_combobox.styleSheet()
device_input_combobox.setEnabled(False)
assert device_input_combobox.styleSheet() == ""
assert "transparent" in device_input_combobox.styleSheet()
device_input_combobox.setEnabled(True)
assert "red" in device_input_combobox.styleSheet()
@@ -139,23 +139,6 @@ def test_device_input_combobox_cleanup_unregisters_callback(qtbot, mocked_client
assert widget._callback_id is None
def test_device_input_combobox_cleanup_clears_callback_before_unregister(qtbot, mocked_client):
widget = DeviceComboBox(client=mocked_client)
qtbot.addWidget(widget)
callback_id = widget._callback_id
def assert_callback_cleared(removed_callback_id):
assert removed_callback_id == callback_id
assert widget._callback_id is None
with mock.patch.object(
mocked_client.callbacks, "remove", side_effect=assert_callback_cleared
) as remove_mock:
widget.cleanup()
remove_mock.assert_called_once_with(callback_id)
def test_get_device_from_input_combobox_init(device_input_combobox):
device_input_combobox.setCurrentIndex(0)
device_text = device_input_combobox.currentText()
+1 -61
View File
@@ -96,22 +96,6 @@ def test_signal_combobox_autocomplete(qtbot, mocked_client):
assert text_changes[-1] == "manual_signal"
def test_signal_combobox_group_headers_are_disabled(qtbot, mocked_client):
widget = create_widget(qtbot=qtbot, widget=SignalComboBox, client=mocked_client)
widget.set_device("samx")
assert widget.itemText(0) == "Hinted Signals"
assert widget.itemText(2) == "Normal Signals"
assert widget.itemText(4) == "Config Signals"
assert widget.model().item(0).isEnabled() is False
assert widget.model().item(2).isEnabled() is False
assert widget.model().item(4).isEnabled() is False
assert widget.set_to_first_enabled() is True
assert widget.currentText() == "samx (readback)"
def test_signal_combobox_qproperties(device_signal_combobox):
device_signal_combobox.include_config_signals = False
device_signal_combobox.include_normal_signals = False
@@ -132,7 +116,7 @@ def test_signal_combobox_disabled_invalid_has_neutral_border(device_signal_combo
assert "red" in device_signal_combobox.styleSheet()
device_signal_combobox.setEnabled(False)
assert device_signal_combobox.styleSheet() == ""
assert "transparent" in device_signal_combobox.styleSheet()
device_signal_combobox.setEnabled(True)
assert "red" in device_signal_combobox.styleSheet()
@@ -212,50 +196,6 @@ def test_device_signal_input_base_cleanup(qtbot, mocked_client):
assert widget._device_update_register is None
def test_signal_combobox_cleanup_clears_callback_before_unregister(qtbot, mocked_client):
widget = create_widget(qtbot=qtbot, widget=SignalComboBox, client=mocked_client)
callback_id = widget._device_update_register
def assert_callback_cleared(removed_callback_id):
assert removed_callback_id == callback_id
assert widget._device_update_register is None
with mock.patch.object(
mocked_client.callbacks, "remove", side_effect=assert_callback_cleared
) as remove_mock:
widget.cleanup()
remove_mock.assert_called_once_with(callback_id)
def test_signal_combobox_cleanup_blocks_in_flight_device_update(qtbot, mocked_client):
widget = create_widget(qtbot=qtbot, widget=SignalComboBox, client=mocked_client)
callback_id = widget._device_update_register
def trigger_in_flight_update(_):
widget.update_signals_from_filters("reload", {})
with (
mock.patch.object(
mocked_client.callbacks, "remove", side_effect=trigger_in_flight_update
) as remove_mock,
mock.patch.object(widget, "_set_signal_groups") as set_signal_groups,
):
widget.cleanup()
remove_mock.assert_called_once_with(callback_id)
set_signal_groups.assert_not_called()
def test_signal_combobox_device_update_ignores_update_action(qtbot, mocked_client):
widget = create_widget(qtbot=qtbot, widget=SignalComboBox, client=mocked_client)
with mock.patch.object(widget, "_set_signal_groups") as set_signal_groups:
widget.update_signals_from_filters("update", {})
set_signal_groups.assert_not_called()
def test_signal_combobox_get_signal_name_with_item_data(qtbot, device_signal_combobox):
"""Test get_signal_name returns obj_name from item data when available."""
device_signal_combobox.include_normal_signals = True
+1 -10
View File
@@ -869,14 +869,7 @@ class TestToolbarFunctionality:
def test_toolbar_utils_actions(self, advanced_dock_area):
"""Test utils toolbar actions trigger widget creation."""
utils_actions = [
"queue",
"terminal",
"status",
"progress_bar",
"sbb_monitor",
"beamline_state_manager",
]
utils_actions = ["queue", "terminal", "status", "progress_bar", "sbb_monitor"]
for action_name in utils_actions:
with patch.object(advanced_dock_area, "new") as mock_new:
@@ -2435,7 +2428,6 @@ class TestFlatToolbarActions:
"flat_terminal",
"flat_bec_shell",
"flat_sbb_monitor",
"flat_beamline_state_manager",
]
for action_name in utils_actions:
@@ -2480,7 +2472,6 @@ class TestFlatToolbarActions:
"flat_terminal": "BecConsole",
"flat_bec_shell": "BECShell",
"flat_sbb_monitor": "SBBMonitor",
"flat_beamline_state_manager": "BeamlineStateManager",
}
for action_name, widget_type in utils_action_mapping.items():
@@ -1,24 +1,10 @@
from decimal import Decimal
from unittest.mock import patch
import pytest
from bec_lib.device import Device, Signal
from bec_lib.scan_args import ScanArgument
from pydantic import BaseModel, Field
from qtpy.QtWidgets import QCheckBox, QLabel, QLineEdit
from bec_widgets.utils.forms_from_types.forms import PydanticModelForm, TypedForm
from bec_widgets.utils.forms_from_types.items import FloatDecimalFormItem, IntFormItem, StrFormItem
from bec_widgets.utils.forms_from_types.pydantic_widget_form import (
OptionalValueWidget,
PydanticWidgetForm,
)
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
from bec_widgets.widgets.utility.spinbox.decimal_spinbox import BECSpinBox
from .client_mocks import mocked_client
# pylint: disable=no-member
# pylint: disable=missing-function-docstring
@@ -40,80 +26,6 @@ class ExampleSchema(BaseModel):
decimal_dp_limits_nodefault: Decimal = Field(decimal_places=2, gt=1, le=34.5)
class GeneratedBeamlineSchema(BaseModel):
name: str = Field(title="State name", description="Unique state identifier.")
title: str | None = Field(default=None, title="Display title", description="Visible title.")
device: Device | str = Field(title="Device", description="BEC device.")
signal: Signal | str | None = Field(
default=None, title="Signal", description="Optional device signal."
)
limit: float | None = Field(
default=None,
title="Limit",
description="Optional numeric limit.",
json_schema_extra={"precision": 6},
)
tolerance: float = Field(
default=0.1,
title="Tolerance",
description="Warning tolerance.",
json_schema_extra={"precision": 6},
)
model_config = {"arbitrary_types_allowed": True}
class GeneratedPlainSchema(BaseModel):
sample_name: str
class GeneratedDeviceOnlySchema(BaseModel):
device: Device | str = Field(default="", title="Device")
model_config = {"arbitrary_types_allowed": True}
class GeneratedSignalOnlySchema(BaseModel):
signal: Signal | str | None = Field(default=None, title="Signal")
model_config = {"arbitrary_types_allowed": True}
class GeneratedScanArgumentSchema(BaseModel):
device: Device | str = Field(
default="", **ScanArgument(display_name="Device", description="Device source.").model_dump()
)
signal: Signal | str | None = Field(
default=None,
**ScanArgument(display_name="Signal", description="Signal source.").model_dump(),
)
low_limit: float | None = Field(
default=None,
**ScanArgument(
display_name="Low limit",
description="Optional lower bound.",
reference_units="device",
precision=4,
ge=-5,
le=5,
).model_dump(),
)
exposure: float = Field(
default=0.1,
**ScanArgument(
display_name="Exposure", tooltip="Camera exposure.", units="s", precision=3, gt=0
).model_dump(),
)
model_config = {"arbitrary_types_allowed": True}
class GeneratedRequiredNumericAndOptionalBoolSchema(BaseModel):
enabled: bool | None = None
retry_count: int
scale: float
TEST_DICT = {
"sample_name": "test name",
"str_optional": "None",
@@ -162,168 +74,3 @@ def test_widget_set_data(model_widget: PydanticModelForm):
"decimal_dp_limits_nodefault",
]:
assert model_widget.widget_dict[key].getValue() == TEST_DICT[key]
def test_pydantic_widget_form_uses_field_metadata_and_type_widgets(qtbot, mocked_client):
form = PydanticWidgetForm(GeneratedBeamlineSchema, client=mocked_client)
qtbot.addWidget(form)
assert isinstance(form.input_widget("name"), QLineEdit)
assert isinstance(form.input_widget("device"), DeviceComboBox)
assert isinstance(form.input_widget("signal"), SignalComboBox)
assert isinstance(form.field_widget("limit"), OptionalValueWidget)
assert isinstance(form.input_widget("limit"), BECSpinBox)
assert form.input_widgets_by_type(DeviceComboBox) == [form.input_widget("device")]
assert form.input_widgets_by_type(SignalComboBox) == [form.input_widget("signal")]
label = form.layout().labelForField(form.field_widget("device"))
assert isinstance(label, QLabel)
assert label.text() == "Device"
assert label.toolTip() == "BEC device."
assert form.field_widget("limit").toolTip() == "Optional numeric limit."
def test_pydantic_widget_form_device_signal_variants(qtbot, mocked_client):
device_signal_form = PydanticWidgetForm(GeneratedBeamlineSchema, client=mocked_client)
device_only_form = PydanticWidgetForm(GeneratedDeviceOnlySchema, client=mocked_client)
signal_only_form = PydanticWidgetForm(GeneratedSignalOnlySchema, client=mocked_client)
qtbot.addWidget(device_signal_form)
qtbot.addWidget(device_only_form)
qtbot.addWidget(signal_only_form)
assert isinstance(device_signal_form.input_widget("device"), DeviceComboBox)
assert isinstance(device_signal_form.input_widget("signal"), SignalComboBox)
assert device_signal_form.input_widget("signal").require_device is True
assert isinstance(device_only_form.input_widget("device"), DeviceComboBox)
assert device_only_form.input_widgets_by_type(SignalComboBox) == []
assert isinstance(signal_only_form.input_widget("signal"), SignalComboBox)
assert signal_only_form.input_widget("signal").require_device is False
assert signal_only_form.input_widgets_by_type(DeviceComboBox) == []
def test_pydantic_widget_form_plain_field_has_generated_label_and_no_tooltip(qtbot):
form = PydanticWidgetForm(GeneratedPlainSchema)
qtbot.addWidget(form)
label = form.layout().labelForField(form.field_widget("sample_name"))
assert isinstance(label, QLabel)
assert label.text() == "Sample name"
assert label.toolTip() == ""
assert form.field_widget("sample_name").toolTip() == ""
def test_pydantic_model_input_configs_reads_bl_states_annotated_scan_arguments():
"""Contract test: ScanArgument metadata attached via ``Annotated`` in bec_lib's beamline
state configs must reach the generated-form configuration."""
from bec_lib import bl_states
from bec_widgets.utils.forms_from_types.pydantic_model_info_adapter import (
pydantic_model_input_configs,
)
items = {
item["name"]: item
for item in pydantic_model_input_configs(bl_states.DeviceWithinLimitsState.CONFIG_CLASS)
}
assert items["name"]["display_name"] == "State name"
assert items["name"]["tooltip"]
assert items["device"]["display_name"] == "Device"
assert items["low_limit"]["reference_units"] == "device"
assert items["high_limit"]["reference_units"] == "device"
assert items["tolerance"]["reference_units"] == "device"
def test_pydantic_widget_form_uses_scan_argument_metadata(qtbot, mocked_client):
form = PydanticWidgetForm(GeneratedScanArgumentSchema, client=mocked_client)
qtbot.addWidget(form)
low_limit = form.field_widget("low_limit")
low_limit_input = form.input_widget("low_limit")
exposure = form.input_widget("exposure")
low_limit_label = form.layout().labelForField(low_limit)
assert isinstance(low_limit_label, QLabel)
assert low_limit_label.text() == "Low limit"
assert low_limit.toolTip() == "Optional lower bound.\nUnits from: device"
assert low_limit_input.toolTip() == "Optional lower bound.\nUnits from: device"
assert low_limit_input.decimals() == 4
assert low_limit_input.minimum() == pytest.approx(-5)
assert low_limit_input.maximum() == pytest.approx(5)
assert form.field_widget("exposure").toolTip() == "Camera exposure.\nUnits: s"
assert exposure.toolTip() == "Camera exposure.\nUnits: s"
assert exposure.suffix() == " s"
assert exposure.decimals() == 3
assert exposure.minimum() == pytest.approx(0.001)
with patch.object(mocked_client.device_manager.devices.samx, "egu", return_value="mm"):
WidgetIO.set_value(form.input_widget("device"), "samx")
assert low_limit.toolTip() == "Optional lower bound.\nUnits: mm"
assert low_limit_input.toolTip() == "Optional lower bound.\nUnits: mm"
assert low_limit_input.suffix() == " mm"
def test_pydantic_widget_form_cleans_up_on_close(qtbot):
form = PydanticWidgetForm(GeneratedPlainSchema)
qtbot.addWidget(form)
form.close()
assert form.widgets == {}
assert form.layout().count() == 0
def test_pydantic_widget_form_round_trips_optional_numeric_and_dirty_state(qtbot, mocked_client):
form = PydanticWidgetForm(
GeneratedBeamlineSchema,
client=mocked_client,
data={"name": "state_1", "title": "State", "device": "samx", "signal": "samx"},
)
qtbot.addWidget(form)
assert form.get_data()["limit"] is None
limit = form.field_widget("limit")
limit.checkbox.setChecked(True)
form.input_widget("limit").setValue(5.0)
assert form.get_data()["limit"] == 5.0
assert form.model_instance().limit == 5.0
assert "limit" in form.dirty_fields()
form.reset_to_baseline()
assert form.get_data()["limit"] is None
assert form.dirty_fields() == set()
def test_pydantic_widget_form_initializes_required_numeric_fields(qtbot):
form = PydanticWidgetForm(GeneratedRequiredNumericAndOptionalBoolSchema)
qtbot.addWidget(form)
assert form.raw_data()["retry_count"] == 0
assert form.raw_data()["scale"] == 0.0
assert form.model_instance().retry_count == 0
assert form.model_instance().scale == 0.0
def test_pydantic_widget_form_preserves_optional_bool_none(qtbot):
form = PydanticWidgetForm(GeneratedRequiredNumericAndOptionalBoolSchema)
qtbot.addWidget(form)
enabled = form.field_widget("enabled")
assert isinstance(enabled, OptionalValueWidget)
assert isinstance(form.input_widget("enabled"), QCheckBox)
assert form.raw_data()["enabled"] is None
assert form.model_instance().enabled is None
enabled.checkbox.setChecked(True)
form.input_widget("enabled").setChecked(True)
assert form.raw_data()["enabled"] is True
assert form.model_instance().enabled is True
+14 -55
View File
@@ -4,9 +4,7 @@ import os
from unittest import mock
import pytest
from qtpy.QtCore import QObject
from qtpy.QtGui import QFontMetrics
from qtpy.QtWidgets import QWidget
import bec_widgets
from bec_widgets.applications.launch_window import START_EMPTY_PROFILE_OPTION, LaunchWindow
@@ -18,28 +16,6 @@ from .client_mocks import mocked_client
base_path = os.path.dirname(bec_widgets.__file__)
def _launcher_child_connection(launcher: LaunchWindow, name: str) -> QObject:
connection = QObject(parent=launcher)
connection.gui_id = f"{launcher.gui_id}:{name}"
connection.setObjectName(name)
return connection
def _top_level_connection(qtbot, name: str) -> QWidget:
connection = QWidget()
connection.gui_id = name
connection.setObjectName(name)
qtbot.addWidget(connection)
return connection
def _unparented_connection(name: str) -> QObject:
connection = QObject()
connection.gui_id = name
connection.setObjectName(name)
return connection
@pytest.fixture
def bec_launch_window(qtbot, mocked_client):
widget = LaunchWindow(client=mocked_client)
@@ -141,20 +117,20 @@ def test_open_dock_area_with_start_empty_option_calls_launch(bec_launch_window):
(["launcher", "dock_area", "scan_progress_simple", "scan_progress_full"], False),
(
["launcher", "dock_area", "scan_progress_simple", "scan_progress_full", "hover_widget"],
False,
True,
),
(["launcher", "external_window"], True),
],
)
def test_gui_server_turns_off_the_lights(bec_launch_window, qtbot, connection_names, hide):
def test_gui_server_turns_off_the_lights(bec_launch_window, connection_names, hide):
connections = {}
for name in connection_names:
conn = mock.MagicMock()
if name == "hover_widget":
conn = _unparented_connection("HoverWidget")
elif name == "external_window":
conn = _top_level_connection(qtbot, "external_window")
conn.parent.return_value = None
conn.objectName.return_value = "HoverWidget"
else:
conn = _launcher_child_connection(bec_launch_window, name)
conn.parent.return_value = mock.MagicMock()
conn.objectName.return_value = bec_launch_window.objectName()
connections[name] = conn
with (
mock.patch.object(bec_launch_window, "show") as mock_show,
@@ -177,23 +153,6 @@ def test_gui_server_turns_off_the_lights(bec_launch_window, qtbot, connection_na
mock_set_quit_on_last_window_closed.assert_called_once_with(True)
def test_launcher_detects_external_main_window(bec_launch_window, qtbot):
connection = _top_level_connection(qtbot, "BECMainWindowNoRPC")
assert bec_launch_window._has_external_window({"window": connection})
def test_launcher_logs_unparented_non_window_connection_once(bec_launch_window):
connection = _unparented_connection("HoverWidget")
with mock.patch("bec_widgets.applications.launch_window.logger.warning") as mock_warning:
bec_launch_window._turn_off_the_lights({"window": connection})
bec_launch_window._turn_off_the_lights({"window": connection})
mock_warning.assert_called_once()
assert "HoverWidget" in mock_warning.call_args.args[0]
@pytest.mark.parametrize(
"connection_names, close_called",
[
@@ -204,12 +163,11 @@ def test_launcher_logs_unparented_non_window_connection_once(bec_launch_window):
(["launcher", "dock_area", "scan_progress_simple", "scan_progress_full"], True),
(
["launcher", "dock_area", "scan_progress_simple", "scan_progress_full", "hover_widget"],
True,
False,
),
(["launcher", "external_window"], False),
],
)
def test_launch_window_closes(bec_launch_window, qtbot, connection_names, close_called):
def test_launch_window_closes(bec_launch_window, connection_names, close_called):
"""
Test that the close event is handled correctly based on the connections.
If there are no connections or only the launcher connection, the window should close.
@@ -217,12 +175,13 @@ def test_launch_window_closes(bec_launch_window, qtbot, connection_names, close_
"""
connections = {}
for name in connection_names:
conn = mock.MagicMock()
if name == "hover_widget":
conn = _unparented_connection("HoverWidget")
elif name == "external_window":
conn = _top_level_connection(qtbot, "external_window")
conn.parent.return_value = None
conn.objectName.return_value = "HoverWidget"
else:
conn = _launcher_child_connection(bec_launch_window, name)
conn.parent.return_value = mock.MagicMock()
conn.objectName.return_value = bec_launch_window.objectName()
connections[name] = conn
close_event = mock.MagicMock()
with mock.patch.object(
+5
View File
@@ -69,6 +69,11 @@ def test_display_app_id_connected(bec_main_window):
assert bec_main_window._app_id_label.text() == "App ID: gui_123"
def test_event_consumes_status_tip(bec_main_window):
status_tip_event = QEvent(QEvent.Type.StatusTip)
assert bec_main_window.event(status_tip_event) is True
def test_get_launcher_from_qapp_returns_none_when_absent(bec_main_window):
with patch.object(
QApplication, "instance", return_value=SimpleNamespace(topLevelWidgets=lambda: [])
-13
View File
@@ -1,13 +0,0 @@
from bec_widgets.utils.name_utils import pascal_to_snake, sanitize_namespace
def test_pascal_to_snake():
assert pascal_to_snake("DeviceWithinLimitsState") == "device_within_limits_state"
assert pascal_to_snake("BECStatusWidget") == "bec_status_widget"
def test_sanitize_namespace():
assert sanitize_namespace("scan 1 / user") == "scan_1_user"
assert sanitize_namespace(" beamline.state-1 ") == "beamline.state-1"
assert sanitize_namespace(" ") is None
assert sanitize_namespace(None) is None
-26
View File
@@ -40,23 +40,11 @@ def test_apply_theme_updates_colours(qtbot, toast):
"""apply_theme("light") should inject LIGHT palette colours into stylesheets."""
toast.apply_theme("light")
assert LIGHT_PALETTE["title"] in toast._title_lbl.styleSheet()
assert "border: 1px solid" in toast.trace_view.styleSheet()
assert "border:none" not in toast.trace_view.styleSheet()
toast.apply_theme("dark")
assert DARK_PALETTE["title"] in toast._title_lbl.styleSheet()
def test_toast_updates_from_qapp_theme_changed_signal(qtbot, toast):
app = QtWidgets.QApplication.instance()
assert hasattr(app, "theme")
app.theme.theme_changed.emit("light")
qtbot.wait(10)
assert LIGHT_PALETTE["title"] in toast._title_lbl.styleSheet()
def test_expired_signal(qtbot, toast):
"""Toast must emit expired once its lifetime finishes."""
with qtbot.waitSignal(toast.expired, timeout=1000):
@@ -263,20 +251,6 @@ def test_theme_propagation(qtbot, centre):
assert LIGHT_PALETTE["title"] in toast._title_lbl.styleSheet()
def test_centre_updates_from_qapp_theme_changed_signal(qtbot, centre):
toast = _post(centre, SeverityKind.INFO)
centre.apply_theme("dark")
app = QtWidgets.QApplication.instance()
assert hasattr(app, "theme")
app.theme.theme_changed.emit("light")
qtbot.wait(10)
assert centre._theme == "light"
assert LIGHT_PALETTE["title"] in toast._title_lbl.styleSheet()
# ------------------------------------------------------------------------
# NotificationIndicator tests
# ------------------------------------------------------------------------
+32 -16
View File
@@ -2,7 +2,7 @@ from unittest import mock
import pytest
from bec_lib.endpoints import MessageEndpoints
from bec_lib.messages import VariableMessage
from bec_lib.messages import ScanQueueMessage
from qtpy.QtCore import Qt, QTimer
from qtpy.QtGui import QValidator
from qtpy.QtWidgets import QPushButton
@@ -34,11 +34,15 @@ class PositionerWithoutPrecision(Positioner):
def positioner_box(qtbot, mocked_client):
"""Fixture for PositionerBox widget"""
with mock.patch(
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base.PositionerBoxBase._check_device_is_valid",
return_value=True,
):
db = create_widget(qtbot, PositionerBox, device="samx", client=mocked_client)
yield db
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base.uuid.uuid4"
) as mock_uuid:
mock_uuid.return_value = "fake_uuid"
with mock.patch(
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base.PositionerBoxBase._check_device_is_valid",
return_value=True,
):
db = create_widget(qtbot, PositionerBox, device="samx", client=mocked_client)
yield db
def test_positioner_box(positioner_box):
@@ -85,8 +89,16 @@ def test_positioner_box_on_stop(positioner_box):
"""Test on stop button"""
with mock.patch.object(positioner_box.client.connector, "send") as mock_send:
positioner_box.on_stop()
msg = VariableMessage(value=["samx"])
mock_send.assert_called_once_with(MessageEndpoints.stop_devices(), msg)
params = {"device": "samx", "rpc_id": "fake_uuid", "func": "stop", "args": [], "kwargs": {}}
msg = ScanQueueMessage(
scan_type="device_rpc",
parameter=params,
queue="emergency",
metadata={"RID": "fake_uuid", "response": False},
)
mock_send.assert_called_once_with(
MessageEndpoints.scan_queue_request(positioner_box.client.username), msg
)
def test_positioner_box_setpoint_change(positioner_box):
@@ -127,15 +139,19 @@ def test_positioner_control_line(qtbot, mocked_client):
Inherits from PositionerBox, but the layout is changed. Check dimensions only
"""
with mock.patch(
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box.PositionerBox._check_device_is_valid",
return_value=True,
):
db = PositionerControlLine(device="samx", client=mocked_client)
qtbot.addWidget(db)
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base.uuid.uuid4"
) as mock_uuid:
mock_uuid.return_value = "fake_uuid"
with mock.patch(
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box.PositionerBox._check_device_is_valid",
return_value=True,
):
db = PositionerControlLine(device="samx", client=mocked_client)
qtbot.addWidget(db)
assert db.ui.device_box.height() == db.height()
assert db.ui.device_box.height() >= db.dimensions[0]
assert db.ui.device_box.width() == 600
assert db.ui.device_box.height() == db.height()
assert db.ui.device_box.height() >= db.dimensions[0]
assert db.ui.device_box.width() == 600
def test_positioner_box_open_dialog_selection(qtbot, positioner_box):
+11 -17
View File
@@ -1,8 +1,6 @@
from unittest import mock
import pytest
from bec_lib.endpoints import MessageEndpoints
from bec_lib.messages import VariableMessage
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox2D
@@ -14,13 +12,17 @@ from .conftest import create_widget
def positioner_box_2d(qtbot, mocked_client):
"""Fixture for PositionerBox widget"""
with mock.patch(
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base.PositionerBoxBase._check_device_is_valid",
return_value=True,
):
db = create_widget(
qtbot, PositionerBox2D, device_hor="samx", device_ver="samy", client=mocked_client
)
yield db
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base.uuid.uuid4"
) as mock_uuid:
mock_uuid.return_value = "fake_uuid"
with mock.patch(
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base.PositionerBoxBase._check_device_is_valid",
return_value=True,
):
db = create_widget(
qtbot, PositionerBox2D, device_hor="samx", device_ver="samy", client=mocked_client
)
yield db
def test_positioner_box_2d(positioner_box_2d):
@@ -80,14 +82,6 @@ def test_positioner_box_setpoint_changes(positioner_box_2d: PositionerBox2D):
mock_move.assert_called_once_with(100, relative=False)
def test_positioner_box_2d_on_stop(positioner_box_2d: PositionerBox2D):
"""Stop button sends both positioners to the immediate stop endpoint."""
with mock.patch.object(positioner_box_2d.client.connector, "send") as mock_send:
positioner_box_2d.on_stop()
msg = VariableMessage(value=["samx", "samy"])
mock_send.assert_called_once_with(MessageEndpoints.stop_devices(), msg)
def _hor_buttons(widget: PositionerBox2D):
return [
widget.ui.tweak_increase_hor,
-179
View File
@@ -1,179 +0,0 @@
from unittest import mock
from bec_lib.endpoints import MessageEndpoints
from bec_widgets.widgets.progress.progress_backend import BECProgressTracker
def _dispatcher():
dispatcher = mock.MagicMock()
return dispatcher
def test_tracker_subscribes_to_scan_progress_immediately():
dispatcher = _dispatcher()
tracker = BECProgressTracker(dispatcher)
tracker.start()
assert dispatcher.connect_slot.call_args_list == [
mock.call(tracker.process_progress_message, MessageEndpoints.scan_progress()),
mock.call(tracker.process_scan_status_message, MessageEndpoints.scan_status()),
]
tracker.cleanup()
def test_tracker_starts_scan_from_scan_progress_metadata():
dispatcher = _dispatcher()
tracker = BECProgressTracker(dispatcher)
snapshots = []
tracker.progress_updated.connect(snapshots.append)
tracker.start()
tracker.process_progress_message(
{"value": 3, "max_value": 10},
{"scan_id": "scan-2", "RID": "rid-2", "scan_number": 2, "status": "open"},
)
assert tracker.task is not None
assert tracker._active_scan_id == "scan-2"
assert tracker._active_rid == "rid-2"
assert tracker.scan_number == 2
assert snapshots[-1].scan_number == 2
tracker.cleanup()
def test_tracker_switches_sources_idempotently():
dispatcher = _dispatcher()
tracker = BECProgressTracker(dispatcher)
tracker.start()
tracker.start()
assert dispatcher.connect_slot.call_count == 2
assert dispatcher.disconnect_slot.call_count == 0
tracker.cleanup()
def test_tracker_resets_progress_on_new_open_scan_status():
dispatcher = _dispatcher()
tracker = BECProgressTracker(dispatcher)
snapshots = []
tracker.progress_updated.connect(snapshots.append)
tracker.start()
snapshot = tracker.process_scan_status_message(
{"scan_id": "scan-1", "scan_number": 7, "status": "open"}, {}
)
assert snapshot is not None
assert snapshot.value == 0
assert snapshot.max_value == 100
assert snapshot.status == "open"
assert snapshot.scan_id == "scan-1"
assert snapshot.scan_number == 7
assert snapshot.is_new_scan is True
assert tracker.task is None
assert tracker.scan_number == 7
assert snapshots[-1] == snapshot
tracker.cleanup()
def test_tracker_ignores_duplicate_open_scan_status():
dispatcher = _dispatcher()
tracker = BECProgressTracker(dispatcher)
snapshots = []
tracker.progress_updated.connect(snapshots.append)
tracker.start()
tracker.process_scan_status_message({"scan_id": "scan-1", "status": "open"}, {})
tracker.process_scan_status_message({"scan_id": "scan-1", "status": "open"}, {})
assert len(snapshots) == 1
tracker.cleanup()
def test_tracker_ignores_open_scan_status_after_progress_for_same_scan():
dispatcher = _dispatcher()
tracker = BECProgressTracker(dispatcher)
snapshots = []
tracker.progress_updated.connect(snapshots.append)
tracker.start()
tracker.process_progress_message(
{"value": 3, "max_value": 10}, {"scan_id": "scan-1", "RID": "rid-1", "status": "open"}
)
snapshot = tracker.process_scan_status_message(
{"scan_id": "scan-1", "scan_number": 7, "status": "open"}, {}
)
assert snapshot is None
assert len(snapshots) == 1
assert snapshots[-1].value == 3
assert tracker.task is not None
assert tracker.task.value == 3
tracker.cleanup()
def test_tracker_ignores_open_scan_status_after_done_progress_for_same_scan():
dispatcher = _dispatcher()
tracker = BECProgressTracker(dispatcher)
snapshots = []
tracker.progress_updated.connect(snapshots.append)
tracker.start()
tracker.process_progress_message(
{"value": 10, "max_value": 10, "done": True},
{"scan_id": "scan-1", "RID": "rid-1", "status": "closed"},
)
snapshot = tracker.process_scan_status_message(
{"scan_id": "scan-1", "scan_number": 7, "status": "open"}, {}
)
assert snapshot is None
assert len(snapshots) == 1
assert snapshots[-1].value == 10
assert tracker.task is None
tracker.cleanup()
def test_tracker_marks_new_scan_only_when_rid_changes():
dispatcher = _dispatcher()
tracker = BECProgressTracker(dispatcher)
snapshots = []
tracker.progress_updated.connect(snapshots.append)
tracker.start()
tracker.process_progress_message({"value": 10, "max_value": 100}, {"RID": "rid-1"})
tracker.process_progress_message({"value": 20, "max_value": 200}, {"RID": "rid-1"})
tracker.process_progress_message({"value": 5, "max_value": 50}, {"RID": "rid-2"})
assert [snapshot.is_new_scan for snapshot in snapshots] == [True, False, True]
assert tracker._active_rid == "rid-2"
tracker.cleanup()
def test_tracker_keeps_partial_value_for_done_scan_progress():
dispatcher = _dispatcher()
tracker = BECProgressTracker(dispatcher)
snapshots = []
tracker.progress_updated.connect(snapshots.append)
tracker.start()
tracker.process_progress_message(
{"value": 4, "max_value": 10, "done": True},
{"scan_id": "scan-1", "RID": "rid-1", "status": "aborted"},
)
assert snapshots[-1].value == 4
assert snapshots[-1].max_value == 10
assert snapshots[-1].done is True
assert tracker.task is None
tracker.cleanup()
+26 -146
View File
@@ -1,9 +1,8 @@
# pylint: disable=missing-function-docstring, missing-module-docstring
from unittest.mock import MagicMock, call
from unittest.mock import MagicMock
import pytest
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtGui import QColor
from bec_widgets.tests.utils import FakeDevice
@@ -77,14 +76,11 @@ def test_set_update_to_scan(ring_widget):
ring_widget.set_update("scan")
assert ring_widget.config.mode == "scan"
assert ring_widget.bec_dispatcher.connect_slot.call_args_list == [
call(
ring_widget.progress_tracker.process_progress_message, MessageEndpoints.scan_progress()
),
call(
ring_widget.progress_tracker.process_scan_status_message, MessageEndpoints.scan_status()
),
]
# Verify that connect_slot was called
ring_widget.bec_dispatcher.connect_slot.assert_called_once()
call_args = ring_widget.bec_dispatcher.connect_slot.call_args
assert call_args[0][0] == ring_widget.on_scan_progress
assert "scan_progress" in str(call_args[0][1])
def test_set_update_from_scan_to_manual(ring_widget):
@@ -101,14 +97,6 @@ def test_set_update_from_scan_to_manual(ring_widget):
assert ring_widget.config.mode == "manual"
assert ring_widget.registered_slot is None
assert ring_widget.bec_dispatcher.disconnect_slot.call_args_list == [
call(
ring_widget.progress_tracker.process_progress_message, MessageEndpoints.scan_progress()
),
call(
ring_widget.progress_tracker.process_scan_status_message, MessageEndpoints.scan_status()
),
]
def test_set_update_to_device(ring_widget_with_device):
@@ -432,7 +420,7 @@ def test_set_direction_counter_clockwise(ring_widget):
###################################
def test_update_device_connection_prefers_progress_signal(ring_widget_with_device):
def test_update_device_connection_with_progress_signal(ring_widget_with_device):
ring_widget = ring_widget_with_device
samx = ring_widget.bec_dispatcher.client.device_manager.devices.samx
samx._info["signals"]["progress"] = {
@@ -444,114 +432,15 @@ def test_update_device_connection_prefers_progress_signal(ring_widget_with_devic
ring_widget.bec_dispatcher.connect_slot = MagicMock()
signal = ring_widget._update_device_connection("samx", "")
ring_widget._update_device_connection("samx", "progress")
assert signal == "progress"
# Should connect to device_progress endpoint
ring_widget.bec_dispatcher.connect_slot.assert_called_once()
call_args = ring_widget.bec_dispatcher.connect_slot.call_args
assert call_args[0][0] == ring_widget.on_device_progress
assert call_args[0][1] == MessageEndpoints.device_progress("samx")
def test_update_device_connection_accepts_explicit_progress_signal(ring_widget_with_device):
ring_widget = ring_widget_with_device
samx = ring_widget.bec_dispatcher.client.device_manager.devices.samx
samx._info["signals"]["progress"] = {
"obj_name": "samx_progress",
"component_name": "progress",
"signal_class": "ProgressSignal",
"kind_str": "hinted",
}
ring_widget.bec_dispatcher.connect_slot = MagicMock()
signal = ring_widget._update_device_connection("samx", "progress")
assert signal == "progress"
ring_widget.bec_dispatcher.connect_slot.assert_called_once()
call_args = ring_widget.bec_dispatcher.connect_slot.call_args
assert call_args[0][0] == ring_widget.on_device_progress
assert call_args[0][1] == MessageEndpoints.device_progress("samx")
def test_update_device_connection_resolves_component_name_to_readback_signal(
ring_widget_with_device,
):
ring_widget = ring_widget_with_device
samx = ring_widget.bec_dispatcher.client.device_manager.devices.samx
samx._info["signals"]["setpoint"] = {
"obj_name": "samx_setpoint",
"component_name": "setpoint",
"signal_class": "Signal",
"kind_str": "normal",
}
ring_widget.bec_dispatcher.connect_slot = MagicMock()
signal = ring_widget._update_device_connection("samx", "setpoint")
assert signal == "setpoint"
ring_widget.bec_dispatcher.connect_slot.assert_called_once()
call_args = ring_widget.bec_dispatcher.connect_slot.call_args
assert call_args[0][0] == ring_widget.on_device_readback
assert call_args[0][1] == MessageEndpoints.device_readback("samx")
def test_update_device_connection_falls_back_to_hinted_signal(ring_widget_with_device):
ring_widget = ring_widget_with_device
ring_widget.bec_dispatcher.connect_slot = MagicMock()
signal = ring_widget._update_device_connection("samx", "")
assert signal == "samx"
ring_widget.bec_dispatcher.connect_slot.assert_called_once()
call_args = ring_widget.bec_dispatcher.connect_slot.call_args
assert call_args[0][0] == ring_widget.on_device_readback
assert call_args[0][1] == MessageEndpoints.device_readback("samx")
def test_update_device_connection_falls_back_to_normal_signal(ring_widget_with_device):
ring_widget = ring_widget_with_device
samx = ring_widget.bec_dispatcher.client.device_manager.devices.samx
samx._info["signals"] = {
"setpoint": {
"obj_name": "samx_setpoint",
"component_name": "setpoint",
"signal_class": "Signal",
"kind_str": "normal",
}
}
ring_widget.bec_dispatcher.connect_slot = MagicMock()
signal = ring_widget._update_device_connection("samx", "")
assert signal == "setpoint"
ring_widget.bec_dispatcher.connect_slot.assert_called_once()
call_args = ring_widget.bec_dispatcher.connect_slot.call_args
assert call_args[0][0] == ring_widget.on_device_readback
assert call_args[0][1] == MessageEndpoints.device_readback("samx")
def test_update_device_connection_rejects_unusable_signal(ring_widget_with_device):
ring_widget = ring_widget_with_device
samx = ring_widget.bec_dispatcher.client.device_manager.devices.samx
samx._info["signals"]["async_signal"] = {
"obj_name": "samx_async",
"component_name": "async_signal",
"signal_class": "AsyncSignal",
"kind_str": "hinted",
}
ring_widget.bec_dispatcher.connect_slot = MagicMock()
with pytest.raises(ValueError, match="not usable for ring progress device mode"):
ring_widget._update_device_connection("samx", "samx_async")
ring_widget.bec_dispatcher.connect_slot.assert_not_called()
def test_update_device_connection_accepts_explicit_hinted_signal(ring_widget):
def test_update_device_connection_with_hinted_signal(ring_widget):
mock_device = FakeDevice(name="samx")
mock_device._info = {
"signals": {
@@ -563,13 +452,12 @@ def test_update_device_connection_accepts_explicit_hinted_signal(ring_widget):
ring_widget.bec_dispatcher.connect_slot = MagicMock()
signal = ring_widget._update_device_connection("samx", "samx")
ring_widget._update_device_connection("samx", "samx")
assert signal == "samx"
# Should connect to device_readback endpoint
ring_widget.bec_dispatcher.connect_slot.assert_called_once()
call_args = ring_widget.bec_dispatcher.connect_slot.call_args
assert call_args[0][0] == ring_widget.on_device_readback
assert call_args[0][1] == MessageEndpoints.device_readback("samx")
def test_update_device_connection_no_device_manager(ring_widget):
@@ -584,52 +472,44 @@ def test_update_device_connection_device_not_found(ring_widget):
mock_device = FakeDevice(name="samx")
ring_widget.bec_dispatcher.client.device_manager.devices["samx"] = mock_device
assert ring_widget._update_device_connection("nonexistent", "signal") == ""
# Should return without raising an error
ring_widget._update_device_connection("nonexistent", "signal")
###################################
# scan progress tests
# on_scan_progress tests
###################################
def test_scan_progress_updates_value(ring_widget):
def test_on_scan_progress_updates_value(ring_widget):
msg = {"value": 42, "max_value": 100}
meta = {"RID": "test_rid_123"}
ring_widget.progress_tracker.process_progress_message(msg, meta)
ring_widget.on_scan_progress(msg, meta)
assert ring_widget.config.value == 42
def test_scan_status_open_resets_scan_progress_value(ring_widget):
ring_widget.set_min_max_values(0, 200)
ring_widget.set_value(80)
ring_widget.progress_tracker.process_scan_status_message(
{"scan_id": "scan-1", "scan_number": 7, "status": "open"}, {}
)
assert ring_widget.config.min_value == 0
assert ring_widget.config.max_value == 100
assert ring_widget.config.value == 0
def test_scan_progress_updates_min_max_on_new_rid(ring_widget):
def test_on_scan_progress_updates_min_max_on_new_rid(ring_widget):
msg = {"value": 50, "max_value": 200}
meta = {"RID": "new_rid"}
ring_widget.progress_tracker.process_progress_message(msg, meta)
ring_widget.RID = "old_rid"
ring_widget.on_scan_progress(msg, meta)
assert ring_widget.config.min_value == 0
assert ring_widget.config.max_value == 200
assert ring_widget.config.value == 50
def test_scan_progress_same_rid_no_min_max_update(ring_widget):
def test_on_scan_progress_same_rid_no_min_max_update(ring_widget):
msg = {"value": 75, "max_value": 300}
meta = {"RID": "same_rid"}
ring_widget.progress_tracker.process_progress_message({"value": 10, "max_value": 100}, meta)
ring_widget.progress_tracker.process_progress_message({"value": 75, "max_value": 300}, meta)
ring_widget.RID = "same_rid"
ring_widget.set_min_max_values(0, 100)
ring_widget.on_scan_progress(msg, meta)
# Max value should not be updated when RID is the same
assert ring_widget.config.max_value == 100
-32
View File
@@ -3,12 +3,10 @@ from unittest.mock import MagicMock
import pytest
from bec_lib.device import DeviceBaseWithConfig, Signal
from bec_widgets.cli.rpc import rpc_base as rpc_base_module
from bec_widgets.cli.rpc.rpc_base import (
DeletedWidgetError,
RPCBase,
RPCReference,
RPCResponseTimeoutError,
_transform_args_kwargs,
)
@@ -53,33 +51,3 @@ def test_transform_args_kwargs():
)
assert args == ("full name", "short name", "string_arg", "full name")
assert kwargs == {"a": "full name", "b": "short name", "c": "string_arg", "d": "full name"}
def test_run_rpc_logs_response_timeout(monkeypatch):
rpc = RPCBase(gui_id="progress_widget", object_name="progressbar")
rpc._rpc_timeout = 0
rpc._client = MagicMock()
info_mock = MagicMock()
error_mock = MagicMock()
monkeypatch.setattr(rpc_base_module.logger, "info", info_mock)
monkeypatch.setattr(rpc_base_module.logger, "error", error_mock)
with pytest.raises(RPCResponseTimeoutError):
rpc._run_rpc("set_value", 42, precision=2, timeout=0)
publish_msg = rpc._client.connector.set_and_publish.call_args.args[1]
assert publish_msg.metadata["method"] == "set_value"
assert publish_msg.metadata["target_gui_id"] == "progress_widget"
assert publish_msg.metadata["object_name"] == "progressbar"
assert publish_msg.metadata["timeout"] == 0
assert publish_msg.metadata["deadline"] == publish_msg.metadata["sent_at"]
assert info_mock.call_count == 1
info_message = info_mock.call_args.args[0]
error_mock.assert_called_once()
error_message = error_mock.call_args.args[0]
assert "GUI RPC response timeout" in error_message
assert "method=set_value" in error_message
assert "target_gui_id=progress_widget" in error_message
assert "object_name=progressbar" in error_message
assert "timeout=0" in error_message
+12 -138
View File
@@ -1,13 +1,11 @@
import argparse
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
from bec_lib.service_config import ServiceConfig
from qtpy.QtWidgets import QWidget
from bec_widgets.applications import companion_app as companion_app_module
from bec_widgets.applications.companion_app import GUIServer
from bec_widgets.utils import rpc_server as rpc_server_module
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.rpc_server import RegistryNotReadyError, RPCServer, SingleshotRPCRepeat
@@ -60,68 +58,6 @@ def test_gui_server_get_service_config(gui_server):
assert gui_server._get_service_config().config == ServiceConfig().config
def test_gui_server_signal_shutdown_closes_widgets_and_quits_app(gui_server):
widget = MagicMock()
gui_server.app = MagicMock()
gui_server.app.topLevelWidgets.return_value = [widget]
gui_server.request_shutdown()
widget.close.assert_called_once()
gui_server.app.quit.assert_called_once()
def test_gui_server_shutdown_is_idempotent(gui_server):
gui_server.launcher_window = MagicMock()
gui_server.dispatcher = MagicMock()
with (
patch.object(companion_app_module.shiboken6, "isValid", return_value=True),
patch.object(companion_app_module.pylsp_server, "is_running", return_value=False),
):
gui_server.shutdown()
gui_server.shutdown()
gui_server.launcher_window.close.assert_called_once()
gui_server.launcher_window.deleteLater.assert_called_once()
gui_server.dispatcher.stop_cli_server.assert_called_once()
gui_server.dispatcher.disconnect_all.assert_called_once()
def test_rpc_server_system_capabilities_include_shutdown(rpc_server):
assert rpc_server.run_system_rpc("system.list_capabilities", [], {}) == {
"system.launch_dock_area": True,
"system.shutdown": True,
}
def test_rpc_server_system_shutdown_requests_gui_server_shutdown(rpc_server, qapp):
gui_server = MagicMock()
qapp.gui_server = gui_server
rpc_server.run_system_rpc("system.shutdown", [], {})
qapp.processEvents()
gui_server.request_shutdown.assert_called_once()
del qapp.gui_server
def test_on_rpc_update_system_shutdown_sends_response_before_return(rpc_server):
order = []
rpc_server.run_system_rpc = MagicMock(side_effect=lambda *_args: order.append("shutdown"))
rpc_server.send_response = MagicMock(side_effect=lambda *_args: order.append("response"))
rpc_server.serialize_result_and_send = MagicMock()
rpc_server.on_rpc_update(
{"action": "system.shutdown", "parameter": {"args": [], "kwargs": {}}},
{"request_id": "shutdown-request", "sent_at": 1.0, "deadline": 10.0, "timeout": 2},
)
assert order == ["shutdown", "response"]
rpc_server.send_response.assert_called_once_with("shutdown-request", True, {"result": None})
rpc_server.serialize_result_and_send.assert_not_called()
def test_singleshot_rpc_repeat_raises_on_repeated_singleshot(rpc_server):
"""
Test that a singleshot RPC method raises an error when called multiple times.
@@ -155,34 +91,22 @@ def test_serialize_result_and_send_with_singleshot_retry(rpc_server, qtbot, dumm
# Third call succeeds
return {"gui_id": dummy.gui_id, "success": True}
warning_mock = MagicMock()
# Patch serialize_object to control when it raises RegistryNotReadyError
with patch.object(rpc_server, "serialize_object", side_effect=serialize_side_effect):
with patch.object(rpc_server, "send_response") as mock_send_response:
with patch.object(rpc_server_module.logger, "warning", warning_mock):
# Start the serialization process
rpc_server._rpc_singleshot_repeats[request_id] = SingleshotRPCRepeat()
rpc_server.serialize_result_and_send(request_id, dummy)
# Start the serialization process
rpc_server._rpc_singleshot_repeats[request_id] = SingleshotRPCRepeat()
rpc_server.serialize_result_and_send(request_id, dummy)
# Verify that serialize_object was called 3 times
qtbot.waitUntil(lambda: call_count >= 3, timeout=5000)
# Verify that serialize_object was called 3 times
qtbot.waitUntil(lambda: call_count >= 3, timeout=5000)
# Verify that send_response was called with success
mock_send_response.assert_called_once()
args = mock_send_response.call_args[0]
assert args[0] == request_id
assert args[1] is True # accepted=True
assert "result" in args[2]
assert warning_mock.call_count == 2
warning_logs = "\n".join(call.args[0] for call in warning_mock.call_args_list)
assert "result serialization delayed; retrying" in warning_logs
assert "request_id=test_request_123" in warning_logs
assert "retry_delay_ms=100" in warning_logs
assert "accumulated_delay_ms=100" in warning_logs
assert "accumulated_delay_ms=200" in warning_logs
assert "max_delay_ms=2000" in warning_logs
# Verify that send_response was called with success
mock_send_response.assert_called_once()
args = mock_send_response.call_args[0]
assert args[0] == request_id
assert args[1] is True # accepted=True
assert "result" in args[2]
def test_serialize_result_and_send_max_delay_exceeded(rpc_server, qtbot, dummy_widget):
@@ -216,56 +140,6 @@ def test_serialize_result_and_send_max_delay_exceeded(rpc_server, qtbot, dummy_w
assert "Max delay exceeded" in args[2]["error"]
def test_send_response_logs_publish_status(rpc_server, monkeypatch):
info_mock = MagicMock()
error_mock = MagicMock()
monkeypatch.setattr(rpc_server_module.logger, "info", info_mock)
monkeypatch.setattr(rpc_server_module.logger, "error", error_mock)
with patch.object(rpc_server.client.connector, "set_and_publish") as publish_mock:
rpc_server.send_response("request-ok", True, {"result": None})
rpc_server.send_response("request-failed", False, {"error": "bad"})
assert publish_mock.call_count == 2
assert "request_id=request-ok" in info_mock.call_args.args[0]
assert "accepted=True" in info_mock.call_args.args[0]
assert "request_id=request-failed" in error_mock.call_args.args[0]
assert "accepted=False" in error_mock.call_args.args[0]
def test_on_rpc_update_logs_late_client_deadline(rpc_server, monkeypatch):
info_mock = MagicMock()
warning_mock = MagicMock()
monkeypatch.setattr(rpc_server_module.logger, "info", info_mock)
monkeypatch.setattr(rpc_server_module.logger, "warning", warning_mock)
rpc_server.rpc_register.get_rpc_by_id = MagicMock()
rpc_server.run_rpc = MagicMock(return_value=None)
rpc_server.serialize_result_and_send = MagicMock()
rpc_server.on_rpc_update(
{
"action": "set_value",
"parameter": {"args": [1], "kwargs": {"source": "test"}, "gui_id": "ring"},
},
{"request_id": "late-request", "timeout": 0.1, "sent_at": 1.0, "deadline": 1.1},
)
received_log = info_mock.call_args_list[0].args[0]
executed_log = info_mock.call_args_list[1].args[0]
warning_logs = "\n".join(call.args[0] for call in warning_mock.call_args_list)
assert "GUI RPC server received request" in received_log
assert "request_id=late-request" in received_log
assert "method=set_value" in received_log
assert "target_gui_id=ring" in received_log
assert "timeout=0.1" in received_log
assert "stale_on_receive=True" in received_log
assert "response_after_client_deadline=True" in executed_log
assert "received request after client timeout deadline" in warning_logs
assert "response is late for client timeout" in warning_logs
def test_run_rpc_delegates_to_rpc_content_class(rpc_server):
class Content:
USER_ACCESS = ["foo", "mode", "mode.setter"]
@@ -1,85 +0,0 @@
import pytest
from qtpy.QtWidgets import QDoubleSpinBox, QSpinBox
from bec_widgets.utils.scan_arg_metadata import (
apply_numeric_limits,
apply_numeric_precision,
apply_unit_metadata,
device_units,
ui_config_from_metadata,
unit_tooltip,
)
from .conftest import create_widget
def test_unit_tooltip_and_cleanup(qtbot):
widget = create_widget(qtbot, QDoubleSpinBox)
item = {"tooltip": "Move start", "reference_units": "device"}
assert unit_tooltip(item) == "Move start\nUnits from: device"
apply_unit_metadata(widget, item)
assert widget.toolTip() == "Move start\nUnits from: device"
assert widget.suffix() == ""
apply_unit_metadata(widget, item, "mm")
assert widget.toolTip() == "Move start\nUnits: mm"
assert widget.suffix() == " mm"
apply_unit_metadata(widget, item, "deg")
assert widget.toolTip() == "Move start\nUnits: deg"
assert widget.suffix() == " deg"
def test_numeric_precision_and_limits(qtbot):
float_widget = create_widget(qtbot, QDoubleSpinBox)
int_widget = create_widget(qtbot, QSpinBox)
apply_numeric_precision(float_widget, {"name": "position", "precision": 3})
apply_numeric_limits(float_widget, {"ge": -1.5, "lt": 2.0})
apply_numeric_limits(int_widget, {"gt": 2, "le": 8})
assert float_widget.decimals() == 3
assert float_widget.minimum() == pytest.approx(-1.5)
assert float_widget.maximum() == pytest.approx(1.999)
assert int_widget.minimum() == 3
assert int_widget.maximum() == 8
def test_device_units_uses_egu():
class Device:
def egu(self):
return "mm"
assert device_units(Device()) == "mm"
assert device_units(object()) is None
def test_ui_config_from_metadata_matches_scan_control_item_shape():
item = ui_config_from_metadata(
name="exp_time",
input_type="float",
default=0.1,
metadata={"tooltip": "Exposure", "units": "s", "precision": 3, "ge": 0},
)
assert item == {
"arg": False,
"name": "exp_time",
"type": "float",
"display_name": "Exp Time",
"tooltip": "Exposure",
"default": 0.1,
"expert": False,
"hidden": False,
"precision": 3,
"units": "s",
"reference_units": None,
"reference_limits": None,
"gt": None,
"ge": 0,
"lt": None,
"le": None,
"alternative_group": None,
}
+266 -138
View File
@@ -2,6 +2,7 @@ from unittest import mock
import numpy as np
import pytest
from bec_lib import messages
from bec_lib.endpoints import MessageEndpoints
from bec_widgets.utils.bec_widget import BECWidget
@@ -9,8 +10,11 @@ from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import (
BECProgressBar,
ProgressState,
)
from bec_widgets.widgets.progress.progress_backend import ProgressTask
from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import ScanProgressBar
from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import (
ProgressSource,
ProgressTask,
ScanProgressBar,
)
from .client_mocks import mocked_client
@@ -23,6 +27,30 @@ def scan_progressbar(qtbot, mocked_client):
yield widget
@pytest.fixture
def scan_message():
return messages.ScanQueueMessage(
metadata={
"file_suffix": None,
"file_directory": None,
"user_metadata": {"sample_name": ""},
"RID": "94949c6e-d5f2-4f01-837e-a5d36257dd5d",
},
scan_type="line_scan",
parameter={
"args": {"samx": [-10.0, 10.0]},
"kwargs": {
"steps": 20,
"relative": False,
"exp_time": 0.1,
"burst_at_each_point": 1,
"system_config": {"file_suffix": None, "file_directory": None},
},
},
queue="primary",
)
def test_progress_task_basic():
"""percentage, remaining, and formatted time helpers behave as expected."""
task = ProgressTask(parent=None, value=50, max_value=100, done=False)
@@ -43,52 +71,18 @@ def test_progress_task_basic():
assert task.time_elapsed == "00:00:10"
def test_progress_task_elapsed_time_uses_monotonic_clock(monkeypatch):
times = iter([100.0, 102.5])
monkeypatch.setattr(
"bec_widgets.widgets.progress.progress_backend.time.monotonic", lambda: next(times)
)
task = ProgressTask(parent=None)
task.timer.stop()
task.update_elapsed_time()
assert task._elapsed_time == 2.5
assert task.time_elapsed == "00:00:02"
def test_scan_progressbar_initialization(scan_progressbar):
assert isinstance(scan_progressbar, ScanProgressBar)
assert isinstance(scan_progressbar.progressbar, BECProgressBar)
def test_scan_progressbar_passes_dynamic_stylesheet_setting(qtbot, mocked_client):
widget = ScanProgressBar(client=mocked_client, enable_dynamic_stylesheet=False)
qtbot.addWidget(widget)
assert widget.progressbar.enable_dynamic_stylesheet is False
def test_scan_progressbar_starts_from_scan_progress_before_queue_update(scan_progressbar):
scan_progressbar.progress_tracker.clear_task(emit_finished=False)
scan_progressbar.progress_tracker.process_progress_message(
{"value": 3, "max_value": 10, "done": False}, metadata={"RID": "live-rid"}
)
assert scan_progressbar.progress_tracker.task is not None
assert scan_progressbar.progress_tracker._active_scan_id == "live-rid"
assert scan_progressbar.progressbar._user_value == 3
assert scan_progressbar.progressbar._user_maximum == 10
def test_update_labels_content(scan_progressbar):
"""update_labels() reflects ProgressTask time strings on the UI."""
# fabricate a task with known timings
task = ProgressTask(parent=scan_progressbar, value=30, max_value=100, done=False)
task.timer.stop()
task._elapsed_time = 50
scan_progressbar.progress_tracker.task = task
scan_progressbar.task = task
scan_progressbar.update_labels()
@@ -96,17 +90,17 @@ def test_update_labels_content(scan_progressbar):
assert scan_progressbar.ui.remaining_time_label.text() == "00:01:57"
def test_progress_update(qtbot, scan_progressbar):
def test_on_progress_update(qtbot, scan_progressbar):
"""
Scan progress updates should update the embedded BECProgressBar
and keep ProgressTask in sync.
on_progress_update() should forward new values to the embedded
BECProgressBar and keep ProgressTask in sync.
"""
task = ProgressTask(parent=scan_progressbar, value=0, max_value=100, done=False)
task.timer.stop()
scan_progressbar.progress_tracker.task = task
scan_progressbar.task = task
msg = {"value": 20, "max_value": 100, "done": False}
scan_progressbar.progress_tracker.process_progress_message(msg, metadata={"status": "open"})
scan_progressbar.on_progress_update(msg, metadata={"status": "open"})
qtbot.wait(200)
bar = scan_progressbar.progressbar
@@ -116,58 +110,14 @@ def test_progress_update(qtbot, scan_progressbar):
assert bar.state is ProgressState.NORMAL
def test_scan_status_open_resets_progress_before_first_progress_update(scan_progressbar):
scan_progressbar.progressbar.set_maximum(50)
scan_progressbar.progressbar.set_value(25)
scan_progressbar.progressbar.state = ProgressState.INTERRUPTED
scan_progressbar.ui.elapsed_time_label.setText("00:00:12")
scan_progressbar.ui.remaining_time_label.setText("00:00:34")
scan_progressbar.progress_tracker.process_scan_status_message(
{"scan_id": "scan-1", "scan_number": 7, "status": "open"}, {"RID": "rid-1"}
)
assert scan_progressbar.progressbar._user_value == 0
assert scan_progressbar.progressbar._user_maximum == 100
assert scan_progressbar.progressbar.state is ProgressState.NORMAL
assert scan_progressbar.ui.elapsed_time_label.text() == "00:00:00"
assert scan_progressbar.ui.remaining_time_label.text() == "00:00:00"
assert scan_progressbar.ui.source_label.text() == "Scan 7"
def test_scan_status_open_reset_ignores_same_scan(scan_progressbar):
scan_progressbar.progress_tracker.process_scan_status_message(
{"scan_id": "scan-1", "status": "open"}, {}
)
scan_progressbar.progressbar.set_value(20)
scan_progressbar.progress_tracker.process_scan_status_message(
{"scan_id": "scan-1", "status": "open"}, {}
)
assert scan_progressbar.progressbar._user_value == 20
def test_scan_status_non_open_does_not_reset_progress(scan_progressbar):
scan_progressbar.progressbar.set_value(25)
scan_progressbar.progress_tracker.process_scan_status_message(
{"scan_id": "scan-1", "status": "closed"}, {}
)
assert scan_progressbar.progressbar._user_value == 25
@pytest.mark.parametrize(
"status, value, max_val, expected_state",
[
("open", 10, 100, ProgressState.NORMAL),
("paused", 25, 100, ProgressState.PAUSED),
("aborted", 30, 100, ProgressState.WARNING),
("halted", 40, 100, ProgressState.INTERRUPTED),
("aborted", 30, 100, ProgressState.INTERRUPTED),
("halted", 40, 100, ProgressState.PAUSED),
("closed", 100, 100, ProgressState.COMPLETED),
("user_completed", 40, 100, ProgressState.COMPLETED),
("UNKNOWN", 10, 100, ProgressState.NORMAL),
],
)
def test_state_mapping_during_updates(
@@ -176,9 +126,9 @@ def test_state_mapping_during_updates(
"""ScanProgressBar should translate BEC status → ProgressState consistently."""
task = ProgressTask(parent=scan_progressbar, value=0, max_value=max_val, done=False)
task.timer.stop()
scan_progressbar.progress_tracker.task = task
scan_progressbar.task = task
scan_progressbar.progress_tracker.process_progress_message(
scan_progressbar.on_progress_update(
{"value": value, "max_value": max_val, "done": status == "closed"},
metadata={"status": status},
)
@@ -186,39 +136,97 @@ def test_state_mapping_during_updates(
assert scan_progressbar.progressbar.state is expected_state
def test_aborted_done_scan_keeps_partial_progress(scan_progressbar):
scan_progressbar.progress_tracker.process_progress_message(
{"value": 4, "max_value": 10, "done": True},
metadata={"scan_id": "scan-1", "RID": "rid-1", "status": "aborted"},
)
assert scan_progressbar.progressbar._user_value == 4
assert scan_progressbar.progressbar._user_maximum == 10
assert scan_progressbar.progressbar.state is ProgressState.WARNING
assert scan_progressbar.progress_tracker.task is None
def test_source_label_updates(scan_progressbar):
"""update_source_label() renders the current scan label."""
scan_progressbar.progress_tracker.scan_number = 5
scan_progressbar.update_source_label()
"""update_source_label() renders correct text for both progress sources."""
# device progress
scan_progressbar.update_source_label(ProgressSource.DEVICE_PROGRESS, device="motor")
assert scan_progressbar.ui.source_label.text() == "Device motor"
# scan progress (needs a scan_number for deterministic text)
scan_progressbar.scan_number = 5
scan_progressbar.update_source_label(ProgressSource.SCAN_PROGRESS)
assert scan_progressbar.ui.source_label.text() == "Scan 5"
def test_source_label_update_logs_only_on_text_change(scan_progressbar):
scan_progressbar.progress_tracker.scan_number = 5
def test_set_progress_source_connections(scan_progressbar, monkeypatch):
""" """
with mock.patch(
"bec_widgets.widgets.progress.scan_progressbar.scan_progressbar.logger.info"
) as mock_info:
scan_progressbar.update_source_label()
scan_progressbar.update_source_label()
scan_progressbar.update_source_label()
connect_calls = []
disconnect_calls = []
mock_info.assert_called_once_with("Set progress source to Scan 5")
def fake_connect(slot, endpoint):
connect_calls.append(endpoint)
def fake_disconnect(slot, endpoint):
disconnect_calls.append(endpoint)
# Patch dispatcher methods
monkeypatch.setattr(scan_progressbar.bec_dispatcher, "connect_slot", fake_connect)
monkeypatch.setattr(scan_progressbar.bec_dispatcher, "disconnect_slot", fake_disconnect)
# switch to SCAN_PROGRESS
scan_progressbar.scan_number = 7
scan_progressbar.set_progress_source(ProgressSource.SCAN_PROGRESS)
assert scan_progressbar._progress_source == ProgressSource.SCAN_PROGRESS
assert scan_progressbar.ui.source_label.text() == "Scan 7"
assert connect_calls[-1] == MessageEndpoints.scan_progress()
assert disconnect_calls == []
# switch to DEVICE_PROGRESS
device = "motor"
scan_progressbar.set_progress_source(ProgressSource.DEVICE_PROGRESS, device=device)
assert scan_progressbar._progress_source == ProgressSource.DEVICE_PROGRESS
assert scan_progressbar.ui.source_label.text() == f"Device {device}"
assert connect_calls[-1] == MessageEndpoints.device_progress(device=device)
assert disconnect_calls == [MessageEndpoints.scan_progress()]
# calling again with the SAME source should not add new connect calls
prev_connect_count = len(connect_calls)
scan_progressbar.set_progress_source(ProgressSource.DEVICE_PROGRESS, device=device)
assert len(connect_calls) == prev_connect_count, "No extra connect made for same source"
def test_cleanup_disconnects_active_scan_subscription(scan_progressbar, monkeypatch):
def test_set_progress_source_disconnects_previous_device_subscription(
scan_progressbar, monkeypatch
):
disconnect_calls = []
monkeypatch.setattr(scan_progressbar.bec_dispatcher, "connect_slot", lambda *args: None)
monkeypatch.setattr(
scan_progressbar.bec_dispatcher,
"disconnect_slot",
lambda slot, endpoint: disconnect_calls.append(endpoint),
)
scan_progressbar.set_progress_source(ProgressSource.DEVICE_PROGRESS, device="motor1")
scan_progressbar.set_progress_source(ProgressSource.DEVICE_PROGRESS, device="motor2")
assert disconnect_calls == [MessageEndpoints.device_progress(device="motor1")]
def test_set_progress_source_disconnects_device_when_switching_to_scan(
scan_progressbar, monkeypatch
):
disconnect_calls = []
monkeypatch.setattr(scan_progressbar.bec_dispatcher, "connect_slot", lambda *args: None)
monkeypatch.setattr(
scan_progressbar.bec_dispatcher,
"disconnect_slot",
lambda slot, endpoint: disconnect_calls.append(endpoint),
)
scan_progressbar.set_progress_source(ProgressSource.DEVICE_PROGRESS, device="motor1")
scan_progressbar.set_progress_source(ProgressSource.SCAN_PROGRESS)
assert disconnect_calls == [MessageEndpoints.device_progress(device="motor1")]
def test_cleanup_disconnects_active_device_subscription(scan_progressbar, monkeypatch):
disconnect_calls = []
@@ -232,28 +240,148 @@ def test_cleanup_disconnects_active_scan_subscription(scan_progressbar, monkeypa
monkeypatch.setattr(scan_progressbar.progressbar, "deleteLater", lambda: None)
monkeypatch.setattr(BECWidget, "cleanup", lambda self: None)
with (
mock.patch.object(scan_progressbar, "close", wraps=scan_progressbar.close) as close_mock,
mock.patch.object(
scan_progressbar, "deleteLater", wraps=scan_progressbar.deleteLater
) as delete_later_mock,
):
ScanProgressBar.cleanup(scan_progressbar)
assert disconnect_calls == [MessageEndpoints.scan_progress(), MessageEndpoints.scan_status()]
assert scan_progressbar.progress_tracker._connected is False
close_mock.assert_not_called()
delete_later_mock.assert_not_called()
def test_cleanup_stops_active_task(scan_progressbar, monkeypatch):
monkeypatch.setattr(BECWidget, "cleanup", lambda self: None)
scan_progressbar.progress_tracker.task = ProgressTask(parent=scan_progressbar)
scan_progressbar.progress_tracker._active_scan_id = "scan-1"
timer = scan_progressbar.progress_tracker.task.timer
scan_progressbar.set_progress_source(ProgressSource.DEVICE_PROGRESS, device="motor1")
ScanProgressBar.cleanup(scan_progressbar)
assert not timer.isActive()
assert scan_progressbar.progress_tracker.task is None
assert scan_progressbar.progress_tracker._active_scan_id is None
assert disconnect_calls == [
MessageEndpoints.device_progress(device="motor1"),
MessageEndpoints.scan_queue_status(),
]
assert scan_progressbar._progress_source is None
assert scan_progressbar._progress_device is None
def test_progressbar_queue_update(scan_progressbar):
"""
Test that an empty queue update does not change the progress source.
"""
msg = messages.ScanQueueStatusMessage(
queue={"primary": messages.ScanQueueStatus(info=[], status="RUNNING")}
)
with mock.patch.object(scan_progressbar, "set_progress_source") as mock_set_source:
scan_progressbar.on_queue_update(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
mock_set_source.assert_not_called()
def test_progressbar_queue_update_with_scan(scan_progressbar, scan_message):
"""
Test that a queue update with a scan changes the progress source to SCAN_PROGRESS.
"""
request_block = messages.RequestBlock(
msg=scan_message,
RID="some-rid",
scan_motors=["samx"],
readout_priority={"monitored": ["samx"]},
is_scan=True,
scan_number=1,
scan_id="e3f50794-852c-4bb1-965e-41c585ab0aa9",
report_instructions=[{"scan_progress": 20}],
)
msg = messages.ScanQueueStatusMessage(
metadata={},
queue={
"primary": messages.ScanQueueStatus(
info=[
messages.QueueInfoEntry(
queue_id="40831e2c-fbd1-4432-8072-ad168a7ad964",
scan_id=["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
status="RUNNING",
active_request_block=request_block,
is_scan=[True],
request_blocks=[request_block],
scan_number=[1],
)
],
status="RUNNING",
)
},
)
with mock.patch.object(scan_progressbar, "set_progress_source") as mock_set_source:
scan_progressbar.on_queue_update(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
mock_set_source.assert_called_once_with(ProgressSource.SCAN_PROGRESS)
def test_progressbar_queue_update_with_device(scan_progressbar, scan_message):
"""
Test that a queue update with a device changes the progress source to DEVICE_PROGRESS.
"""
request_block = messages.RequestBlock(
msg=scan_message,
RID="some-rid",
scan_motors=["samx"],
readout_priority={"monitored": ["samx"]},
is_scan=True,
scan_number=1,
scan_id="e3f50794-852c-4bb1-965e-41c585ab0aa9",
report_instructions=[{"device_progress": ["samx"]}],
)
msg = messages.ScanQueueStatusMessage(
metadata={},
queue={
"primary": messages.ScanQueueStatus(
info=[
messages.QueueInfoEntry(
queue_id="40831e2c-fbd1-4432-8072-ad168a7ad964",
scan_id=["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
status="RUNNING",
active_request_block=request_block,
is_scan=[True],
request_blocks=[request_block],
scan_number=[1],
)
],
status="RUNNING",
)
},
)
with mock.patch.object(scan_progressbar, "set_progress_source") as mock_set_source:
scan_progressbar.on_queue_update(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
mock_set_source.assert_called_once_with(ProgressSource.DEVICE_PROGRESS, device="samx")
def test_progressbar_queue_update_with_no_scan_or_device(scan_progressbar, scan_message):
"""
Test that a queue update with neither scan nor device does not change the progress source.
"""
request_block = messages.RequestBlock(
msg=scan_message,
RID="some-rid",
scan_motors=["samx"],
readout_priority={"monitored": ["samx"]},
is_scan=True,
scan_number=1,
scan_id="e3f50794-852c-4bb1-965e-41c585ab0aa9",
)
msg = messages.ScanQueueStatusMessage(
metadata={},
queue={
"primary": messages.ScanQueueStatus(
info=[
messages.QueueInfoEntry(
queue_id="40831e2c-fbd1-4432-8072-ad168a7ad964",
scan_id=["e3f50794-852c-4bb1-965e-41c585ab0aa9"],
status="RUNNING",
active_request_block=request_block,
is_scan=[True],
request_blocks=[request_block],
scan_number=[1],
)
],
status="RUNNING",
)
},
)
with mock.patch.object(scan_progressbar, "set_progress_source") as mock_set_source:
scan_progressbar.on_queue_update(
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
)
mock_set_source.assert_not_called()
-23
View File
@@ -36,26 +36,3 @@ def test_toggle_click(qtbot, toggle):
qtbot.mouseClick(toggle, Qt.LeftButton)
toggle.paintEvent(None)
assert toggle.checked is not init_state
def test_toggle_disabled_state_blocks_clicks_and_restores_colors(qtbot, toggle):
toggle.checked = True
assert toggle._track_color == toggle.active_track_color
assert toggle._thumb_color == toggle.active_thumb_color
toggle.setEnabled(False)
assert toggle._track_color == toggle._disabled_track_color
assert toggle._thumb_color == toggle._disabled_thumb_color
qtbot.mouseClick(toggle, Qt.LeftButton)
assert toggle.checked is True
assert toggle._track_color == toggle._disabled_track_color
assert toggle._thumb_color == toggle._disabled_thumb_color
toggle.setEnabled(True)
assert toggle.checked is True
assert toggle._track_color == toggle.active_track_color
assert toggle._thumb_color == toggle.active_thumb_color
-56
View File
@@ -16,12 +16,8 @@ from qtpy.QtWidgets import (
)
from bec_widgets.utils.widget_io import WidgetHierarchy, WidgetIO, WidgetTreeNode
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
from .client_mocks import mocked_client
@pytest.fixture(scope="function")
def example_widget(qtbot):
@@ -200,58 +196,6 @@ def test_widget_io_signal(qtbot, example_widget):
assert changes[-1][1] == False
def test_widget_io_device_combobox_handler(qtbot, mocked_client):
widget = DeviceComboBox(client=mocked_client)
qtbot.addWidget(widget)
changes = []
WidgetIO.connect_widget_change_signal(widget, lambda _widget, value: changes.append(value))
WidgetIO.set_value(widget, "samx")
assert WidgetIO.get_value(widget) == "samx"
assert changes[-1] == "samx"
def test_widget_io_device_combobox_handler_accepts_subclasses(qtbot, mocked_client):
class PromotedDeviceComboBox(DeviceComboBox):
pass
widget = PromotedDeviceComboBox(client=mocked_client)
qtbot.addWidget(widget)
WidgetIO.set_value(widget, "samx")
assert WidgetIO.get_value(widget) == "samx"
def test_widget_io_signal_combobox_handler(qtbot, mocked_client):
widget = SignalComboBox(client=mocked_client, require_device=True)
qtbot.addWidget(widget)
changes = []
widget.set_device("samx")
WidgetIO.connect_widget_change_signal(widget, lambda _widget, value: changes.append(value))
WidgetIO.set_value(widget, "samx")
assert WidgetIO.get_value(widget) == "samx"
widget.setCurrentText("")
widget.setCurrentText("samx")
assert changes[-1] == "samx"
def test_widget_io_signal_combobox_handler_accepts_subclasses(qtbot, mocked_client):
class PromotedSignalComboBox(SignalComboBox):
pass
widget = PromotedSignalComboBox(client=mocked_client, require_device=True)
qtbot.addWidget(widget)
widget.set_device("samx")
WidgetIO.set_value(widget, "samx")
assert WidgetIO.get_value(widget) == "samx"
def test_find_widgets(example_widget):
# Test find_widgets by class type
line_edits = WidgetIO.find_widgets(QLineEdit)