1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-10 02:30:54 +02:00

Compare commits

...

32 Commits

Author SHA1 Message Date
fde7b4db6c scratch - device manager 2025-08-18 09:59:54 +02:00
semantic-release
a2f8880459 2.35.0
Automatically generated by python-semantic-release
2025-08-14 07:16:53 +00:00
926d722955 feat(property_manager): property manager widget 2025-08-14 09:16:04 +02:00
44ba7201b4 build: PySide6 upgraded to 6.9.0 2025-08-12 19:56:29 +02:00
semantic-release
0717426db2 2.34.0
Automatically generated by python-semantic-release
2025-08-07 13:39:47 +00:00
f4af6ebc5f fix: use better source for plugin repo name 2025-08-07 15:39:07 +02:00
a923f12c97 feat: autoformat compiled file and add docs 2025-08-07 15:39:07 +02:00
a5a7607a83 tests: add tests for widget creator 2025-08-07 15:39:07 +02:00
9de548446b fix: plugin widget import machinery
- lazy import client so plugin widgets can import BECWidgets which use
  it indirectly
- exclude classes originating from bec_widgets core from plugin
  discovery
- better errors
2025-08-07 15:39:07 +02:00
49ac7decf7 feat(plugin manager): add cli commands 2025-08-07 15:39:07 +02:00
semantic-release
092bed38fa 2.33.3
Automatically generated by python-semantic-release
2025-07-31 11:10:38 +00:00
50c84a766a refactor(scan-history): add spinner for loading time of history 2025-07-31 13:09:47 +02:00
d22a3317ba refactor: use client callback for scan history reload 2025-07-31 13:09:47 +02:00
6df1d0c31f fix(scan-history-view): account for async loading of scan history 2025-07-31 13:09:47 +02:00
946752a4b0 refactor(scan-history): fix insert logic; cleanup 2025-07-31 13:09:47 +02:00
c1f62ad6cb refactor: make ids a set, cleanup 2025-07-31 13:09:47 +02:00
a5adf3a97d refactor: improve scan history performance on loading full scan lists 2025-07-31 13:09:47 +02:00
semantic-release
76e3e0b60f 2.33.2
Automatically generated by python-semantic-release
2025-07-31 07:27:50 +00:00
f18eeb9c5d fix: don't warn on empty DeviceEdit init 2025-07-31 09:26:59 +02:00
32ce8e2818 fix: remove config, directly set device+signal 2025-07-31 09:26:59 +02:00
23413cffab fix: delete choice dialog on close 2025-07-31 09:26:59 +02:00
David Perl
4bbb8fa519 fix: display short lists in SignalDisplay 2025-07-31 09:26:59 +02:00
semantic-release
a972369a72 2.33.1
Automatically generated by python-semantic-release
2025-07-31 06:50:30 +00:00
cd81e7f9ba fix(cli): ensure guis are not started twice 2025-07-31 08:49:48 +02:00
semantic-release
e2b8118f67 2.33.0
Automatically generated by python-semantic-release
2025-07-29 13:24:20 +00:00
5f925ba4e3 build: update bec and qtmonaco min dependencies 2025-07-29 15:23:36 +02:00
fc68d2cf2d feat(monaco): add insert, delete and lsp header 2025-07-29 15:23:36 +02:00
627b49b33a feat(monaco): add vim mode 2025-07-29 15:23:36 +02:00
a51ef04cdf fix(monaco): forward text changed signal 2025-07-29 15:23:36 +02:00
40f4bce285 test(web console): add tests for the web console 2025-07-29 15:23:36 +02:00
2b9fe6c959 feat(web console): add signal to indicate when the js backend is initialized 2025-07-29 15:23:36 +02:00
c2e16429c9 feat(web console): add set_readonly method 2025-07-29 15:23:36 +02:00
22 changed files with 3371 additions and 126 deletions

View File

@@ -1,6 +1,123 @@
# CHANGELOG
## v2.35.0 (2025-08-14)
### Build System
- Pyside6 upgraded to 6.9.0
([`44ba720`](https://github.com/bec-project/bec_widgets/commit/44ba7201b4914d63281bbed5e62d07e5c240595a))
### Features
- **property_manager**: Property manager widget
([`926d722`](https://github.com/bec-project/bec_widgets/commit/926d7229559d189d382fe034b3afbc544e709efa))
## v2.34.0 (2025-08-07)
### Bug Fixes
- Plugin widget import machinery
([`9de5484`](https://github.com/bec-project/bec_widgets/commit/9de548446b9975c0f692757c66ffa07b9a849f15))
- lazy import client so plugin widgets can import BECWidgets which use it indirectly - exclude
classes originating from bec_widgets core from plugin discovery - better errors
- Use better source for plugin repo name
([`f4af6eb`](https://github.com/bec-project/bec_widgets/commit/f4af6ebc5fabf5b62ec87b580476d93d52690b08))
### Features
- Autoformat compiled file and add docs
([`a923f12`](https://github.com/bec-project/bec_widgets/commit/a923f12c974192909222fcada9eca97325866d74))
- **plugin manager**: Add cli commands
([`49ac7de`](https://github.com/bec-project/bec_widgets/commit/49ac7decf7d4cf461e6437f7285dc6967ee36d96))
## v2.33.3 (2025-07-31)
### Bug Fixes
- **scan-history-view**: Account for async loading of scan history
([`6df1d0c`](https://github.com/bec-project/bec_widgets/commit/6df1d0c31fb58c25b01e95e2247277ff2dd5d00e))
### Refactoring
- Improve scan history performance on loading full scan lists
([`a5adf3a`](https://github.com/bec-project/bec_widgets/commit/a5adf3a97d9ff05cef833445c1e6cd8f35a9a2fa))
- Make ids a set, cleanup
([`c1f62ad`](https://github.com/bec-project/bec_widgets/commit/c1f62ad6cb00d9b392a8e0b6247f5260dfb37256))
- Use client callback for scan history reload
([`d22a331`](https://github.com/bec-project/bec_widgets/commit/d22a3317baeccfcc4e074dcef4e3912301d210c5))
- **scan-history**: Add spinner for loading time of history
([`50c84a7`](https://github.com/bec-project/bec_widgets/commit/50c84a766a2b021768fb2c0e8ee00b8e5f058ba7))
- **scan-history**: Fix insert logic; cleanup
([`946752a`](https://github.com/bec-project/bec_widgets/commit/946752a4b05804c2f59cb5c21e4c1d11709a7d44))
## v2.33.2 (2025-07-31)
### Bug Fixes
- Delete choice dialog on close
([`23413cf`](https://github.com/bec-project/bec_widgets/commit/23413cffabe721e35bb5bb726ec34d74dc4ffe05))
- Display short lists in SignalDisplay
([`4bbb8fa`](https://github.com/bec-project/bec_widgets/commit/4bbb8fa519e8a90eebfcfa34e157493c9baa7880))
- Don't warn on empty DeviceEdit init
([`f18eeb9`](https://github.com/bec-project/bec_widgets/commit/f18eeb9c5dccbd9348b6ee6d1477a8b7925d40fc))
- Remove config, directly set device+signal
([`32ce8e2`](https://github.com/bec-project/bec_widgets/commit/32ce8e2818ceacda87e48399e3ed4df0cabb2335))
## v2.33.1 (2025-07-31)
### Bug Fixes
- **cli**: Ensure guis are not started twice
([`cd81e7f`](https://github.com/bec-project/bec_widgets/commit/cd81e7f9ba40be23f6b930d250f743276720b277))
## v2.33.0 (2025-07-29)
### Bug Fixes
- **monaco**: Forward text changed signal
([`a51ef04`](https://github.com/bec-project/bec_widgets/commit/a51ef04cdf0ac8abdb7008d78b13c75b86ce9e06))
### Build System
- Update bec and qtmonaco min dependencies
([`5f925ba`](https://github.com/bec-project/bec_widgets/commit/5f925ba4e3840219e4473d6346ece6746076f718))
### Features
- **monaco**: Add insert, delete and lsp header
([`fc68d2c`](https://github.com/bec-project/bec_widgets/commit/fc68d2cf2d6b161d8e3b9fc9daf6185d9197deba))
- **monaco**: Add vim mode
([`627b49b`](https://github.com/bec-project/bec_widgets/commit/627b49b33a30e45b2bfecb57f090eecfa31af09d))
- **web console**: Add set_readonly method
([`c2e1642`](https://github.com/bec-project/bec_widgets/commit/c2e16429c91de7cc0e672ba36224e9031c1c4234))
- **web console**: Add signal to indicate when the js backend is initialized
([`2b9fe6c`](https://github.com/bec-project/bec_widgets/commit/2b9fe6c9590c8d18b7542307273176e118828681))
### Testing
- **web console**: Add tests for the web console
([`40f4bce`](https://github.com/bec-project/bec_widgets/commit/40f4bce2854bcf333ce261229bd1703b80ced538))
## v2.32.0 (2025-07-29)
### Features

View File

@@ -2469,6 +2469,26 @@ class MonacoWidget(RPCBase):
Get the current text from the Monaco editor.
"""
@rpc_call
def insert_text(self, text: str, line: int | None = None, column: int | None = None) -> None:
"""
Insert text at the current cursor position or at a specified line and column.
Args:
text (str): The text to insert.
line (int, optional): The line number (1-based) to insert the text at. Defaults to None.
column (int, optional): The column number (1-based) to insert the text at. Defaults to None.
"""
@rpc_call
def delete_line(self, line: int | None = None) -> None:
"""
Delete a line in the Monaco editor.
Args:
line (int, optional): The line number (1-based) to delete. If None, the current line will be deleted.
"""
@rpc_call
def set_language(self, language: str) -> None:
"""
@@ -2542,6 +2562,34 @@ class MonacoWidget(RPCBase):
enabled (bool): If True, the minimap will be enabled; otherwise, it will be disabled.
"""
@rpc_call
def set_vim_mode_enabled(self, enabled: bool) -> None:
"""
Enable or disable Vim mode in the Monaco editor.
Args:
enabled (bool): If True, Vim mode will be enabled; otherwise, it will be disabled.
"""
@rpc_call
def set_lsp_header(self, header: str) -> None:
"""
Set the LSP (Language Server Protocol) header for the Monaco editor.
The header is used to provide context for language servers but is not displayed in the editor.
Args:
header (str): The LSP header to set.
"""
@rpc_call
def get_lsp_header(self) -> str:
"""
Get the current LSP header set in the Monaco editor.
Returns:
str: The LSP header.
"""
class MotorMap(RPCBase):
"""Motor map widget for plotting motor positions in 2D including a trace of the last points."""
@@ -4515,6 +4563,20 @@ class SignalLabel(RPCBase):
Displays the full data from array signals if set to True.
"""
@property
@rpc_call
def max_list_display_len(self) -> "int":
"""
For small lists, the max length to display
"""
@max_list_display_len.setter
@rpc_call
def max_list_display_len(self) -> "int":
"""
For small lists, the max length to display
"""
class SignalLineEdit(RPCBase):
"""Line edit widget for device input with autocomplete for device names."""

View File

@@ -14,18 +14,21 @@ from typing import TYPE_CHECKING, Literal, TypeAlias, cast
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import_from
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
from rich.console import Console
from rich.table import Table
import bec_widgets.cli.client as client
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
from bec_widgets.utils.serialization import register_serializer_extension
if TYPE_CHECKING: # pragma: no cover
from bec_lib.messages import GUIRegistryStateMessage
import bec_widgets.cli.client as client
else:
GUIRegistryStateMessage = lazy_import_from("bec_lib.messages", "GUIRegistryStateMessage")
client = lazy_import("bec_widgets.cli.client")
logger = bec_logger.logger
@@ -151,8 +154,10 @@ def wait_for_server(client: BECGuiClient):
raise RuntimeError("GUI is not alive")
try:
if client._gui_started_event.wait(timeout=timeout):
client._gui_started_timer.cancel()
client._gui_started_timer.join()
if client._gui_started_timer is not None:
# cancel the timer, we are done
client._gui_started_timer.cancel()
client._gui_started_timer.join()
else:
raise TimeoutError("Could not connect to GUI server")
finally:
@@ -261,13 +266,20 @@ class BECGuiClient(RPCBase):
def start(self, wait: bool = False) -> None:
"""Start the GUI server."""
logger.warning("Using <gui>.start() is deprecated, use <gui>.show() instead.")
return self._start(wait=wait)
def show(self):
"""Show the GUI window."""
def show(self, wait=True) -> None:
"""
Show the GUI window.
If the GUI server is not running, it will be started.
Args:
wait(bool): Whether to wait for the server to start. Defaults to True.
"""
if self._check_if_server_is_alive():
return self._show_all()
return self.start(wait=True)
return self._start(wait=wait)
def hide(self):
"""Hide the GUI window."""
@@ -382,6 +394,9 @@ class BECGuiClient(RPCBase):
"""
Start the GUI server, and execute callback when it is launched
"""
if self._gui_is_alive():
self._gui_started_event.set()
return
if self._process is None or self._process.poll() is not None:
logger.success("GUI starting...")
self._startup_timeout = 5
@@ -524,7 +539,7 @@ if __name__ == "__main__": # pragma: no cover
# Test the client_utils.py module
gui = BECGuiClient()
gui.start(wait=True)
gui.show(wait=True)
gui.new().new(widget="Waveform")
time.sleep(10)
finally:

View File

@@ -38,9 +38,11 @@ def _loaded_submodules_from_specs(
try:
submodule.__loader__.exec_module(submodule)
except Exception as e:
logger.error(
f"Error loading plugin {submodule}: \n{''.join(traceback.format_exception(e))}"
)
exception_text = "".join(traceback.format_exception(e))
if "(most likely due to a circular import)" in exception_text:
logger.warning(f"Circular import encountered while loading {submodule}")
else:
logger.error(f"Error loading plugin {submodule}: \n{exception_text}")
yield submodule
@@ -59,7 +61,8 @@ def _get_widgets_from_module(module: ModuleType) -> BECClassContainer:
module,
predicate=lambda item: inspect.isclass(item)
and issubclass(item, BECWidget)
and item is not BECWidget,
and item is not BECWidget
and not item.__module__.startswith("bec_widgets"),
)
return BECClassContainer(
BECClassInfo(name=k, module=module.__name__, file=module.__loader__.get_filename(), obj=v)

View File

@@ -0,0 +1,86 @@
import traceback
from pathlib import Path
from typing import Annotated
import copier
import typer
from bec_lib.logger import bec_logger
from bec_lib.plugin_helper import plugin_repo_path
from bec_lib.utils.plugin_manager._constants import ANSWER_KEYS
from bec_lib.utils.plugin_manager._util import existing_data, git_stage_files, make_commit
from bec_widgets.utils.bec_plugin_manager.edit_ui import open_and_watch_ui_editor
logger = bec_logger.logger
_app = typer.Typer(rich_markup_mode="rich")
def _commit_added_widget(repo: Path, name: str):
git_stage_files(repo, [".copier-answers.yml"])
git_stage_files(repo / repo.name / "bec_widgets" / "widgets" / name, [])
make_commit(repo, f"plugin-manager added new widget: {name}")
logger.info(f"Committing new widget {name}")
def _widget_exists(widget_list: list[dict[str, str | bool]], name: str):
return name in [w["name"] for w in widget_list]
def _editor_cb(ctx: typer.Context, value: bool):
if value and not ctx.params["use_ui"]:
raise typer.BadParameter("Can only open the editor if creating a .ui file!")
return value
_bold_blue = "\033[34m\033[1m"
_off = "\033[0m"
_USE_UI_MSG = "Generate a .ui file for use in bec-designer."
_OPEN_DESIGNER_MSG = f"""This app can watch for changes and recompile them to a python file imported to the widget whenever it is saved.
To open this editor independently, you can use {_bold_blue}bec-plugin-manager edit-ui [widget_name]{_off}.
Open the created widget .ui file in bec-designer now?"""
@_app.command()
def widget(
name: Annotated[str, typer.Argument(help="Enter a name for your widget in snake_case")],
use_ui: Annotated[bool, typer.Option(prompt=_USE_UI_MSG, help=_USE_UI_MSG)] = True,
open_editor: Annotated[
bool, typer.Option(prompt=_OPEN_DESIGNER_MSG, help=_OPEN_DESIGNER_MSG, callback=_editor_cb)
] = True,
):
"""Create a new widget plugin with the given name.
If [bold white]use_ui[/bold white] is set, a bec-designer .ui file will also be created. If \
[bold white]open_editor[/bold white] is additionally set, the .ui file will be opened in \
bec-designer and the compiled python version will be updated when changes are made and saved."""
if (formatted_name := name.lower().replace("-", "_")) != name:
logger.warning(f"Adjusting widget name from {name} to {formatted_name}")
if not formatted_name.isidentifier():
logger.error(
f"{name} is not a valid name for a widget (even after converting to {formatted_name}) - please enter something in snake_case"
)
exit(-1)
logger.info(f"Adding new widget {formatted_name} to the template...")
try:
repo = Path(plugin_repo_path())
plugin_data = existing_data(repo, [ANSWER_KEYS.VERSION, ANSWER_KEYS.WIDGETS])
if _widget_exists(plugin_data[ANSWER_KEYS.WIDGETS], formatted_name):
logger.error(f"Widget {formatted_name} already exists!")
exit(-1)
plugin_data[ANSWER_KEYS.WIDGETS].append({"name": formatted_name, "use_ui": use_ui})
copier.run_update(
repo,
data=plugin_data,
defaults=True,
unsafe=True,
overwrite=True,
vcs_ref=plugin_data[ANSWER_KEYS.VERSION],
)
_commit_added_widget(repo, formatted_name)
except Exception:
logger.error(traceback.format_exc())
logger.error("exiting...")
exit(-1)
logger.success(f"Added widget {formatted_name}!")
if open_editor:
open_and_watch_ui_editor(formatted_name)

View File

@@ -0,0 +1,136 @@
import re
import subprocess
from pathlib import Path
from bec_lib.logger import bec_logger
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
from watchdog.events import (
DirCreatedEvent,
DirModifiedEvent,
DirMovedEvent,
FileCreatedEvent,
FileModifiedEvent,
FileMovedEvent,
FileSystemEvent,
FileSystemEventHandler,
)
from watchdog.observers import Observer
from bec_widgets.utils.bec_designer import open_designer
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.plugin_utils import get_custom_classes
logger = bec_logger.logger
class RecompileHandler(FileSystemEventHandler):
def __init__(self, in_file: Path, out_file: Path) -> None:
super().__init__()
self.in_file = str(in_file)
self.out_file = str(out_file)
self._pyside_import_re = re.compile(r"from PySide6\.(.*) import ")
self._widget_import_re = re.compile(
r"^from ([a-zA-Z_]*) import ([a-zA-Z_]*)$", re.MULTILINE
)
self._widget_modules = {
c.name: c.module for c in (get_custom_classes("bec_widgets") + get_all_plugin_widgets())
}
def on_created(self, event: DirCreatedEvent | FileCreatedEvent) -> None:
self.recompile(event)
def on_modified(self, event: DirModifiedEvent | FileModifiedEvent) -> None:
self.recompile(event)
def on_moved(self, event: DirMovedEvent | FileMovedEvent) -> None:
self.recompile(event)
def recompile(self, event: FileSystemEvent) -> None:
if event.src_path == self.in_file or event.dest_path == self.in_file:
self._recompile()
def _recompile(self):
logger.success(".ui file modified, recompiling...")
code = subprocess.call(
["pyside6-uic", "--absolute-imports", self.in_file, "-o", self.out_file]
)
logger.success(f"compilation exited with code {code}")
if code != 0:
return
self._add_comment_to_file()
logger.success("updating imports...")
self._update_imports()
logger.success("formatting...")
code = subprocess.call(
["black", "--line-length=100", "--skip-magic-trailing-comma", self.out_file]
)
if code != 0:
logger.error(f"Error while running black on {self.out_file}, code: {code}")
return
code = subprocess.call(
[
"isort",
"--line-length=100",
"--profile=black",
"--multi-line=3",
"--trailing-comma",
self.out_file,
]
)
if code != 0:
logger.error(f"Error while running isort on {self.out_file}, code: {code}")
return
logger.success("done!")
def _add_comment_to_file(self):
with open(self.out_file, "r+") as f:
initial = f.read()
f.seek(0)
f.write(f"# Generated from {self.in_file} by bec-plugin-manager - do not edit! \n")
f.write(
"# Use 'bec-plugin-manager edit-ui [widget_name]' to make changes, and this file will be updated accordingly. \n\n"
)
f.write(initial)
def _update_imports(self):
with open(self.out_file, "r+") as f:
initial = f.read()
f.seek(0)
qtpy_imports = re.sub(
self._pyside_import_re, lambda ob: f"from qtpy.{ob.group(1)} import ", initial
)
print(self._widget_modules)
print(re.findall(self._widget_import_re, qtpy_imports))
widget_imports = re.sub(
self._widget_import_re,
lambda ob: (
f"from {module} import {ob.group(2)}"
if (module := self._widget_modules.get(ob.group(2))) is not None
else ob.group(1)
),
qtpy_imports,
)
f.write(widget_imports)
f.truncate()
def open_and_watch_ui_editor(widget_name: str):
logger.info(f"Opening the editor for {widget_name}, and watching")
repo = Path(plugin_repo_path())
widget_dir = repo / plugin_package_name() / "bec_widgets" / "widgets" / widget_name
ui_file = widget_dir / f"{widget_name}.ui"
ui_outfile = widget_dir / f"{widget_name}_ui.py"
logger.info(
f"Opening the editor for {widget_name}, and watching {ui_file} for changes. Whenever you save the file, it will be recompiled to {ui_outfile}"
)
recompile_handler = RecompileHandler(ui_file, ui_outfile)
observer = Observer()
observer.schedule(recompile_handler, str(ui_file.parent))
observer.start()
try:
open_designer([str(ui_file)])
finally:
observer.stop()
observer.join()
logger.info("Editing session ended, exiting...")

View File

@@ -0,0 +1,694 @@
from __future__ import annotations
from qtpy.QtCore import QLocale, QMetaEnum, Qt, QTimer
from qtpy.QtGui import QColor, QCursor, QFont, QIcon, QPalette
from qtpy.QtWidgets import (
QCheckBox,
QColorDialog,
QComboBox,
QDoubleSpinBox,
QFileDialog,
QFontDialog,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMenu,
QPushButton,
QSizePolicy,
QSpinBox,
QToolButton,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
class PropertyEditor(QWidget):
def __init__(self, target: QWidget, parent: QWidget | None = None, show_only_bec: bool = True):
super().__init__(parent)
self._target = target
self._bec_only = show_only_bec
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# Name row
name_row = QHBoxLayout()
name_row.addWidget(QLabel("Name:"))
self.name_edit = QLineEdit(target.objectName())
self.name_edit.setEnabled(False) # TODO implement with RPC broadcast
name_row.addWidget(self.name_edit)
layout.addLayout(name_row)
# BEC only checkbox
filter_row = QHBoxLayout()
self.chk_show_qt = QCheckBox("Show Qt properties")
self.chk_show_qt.setChecked(False)
filter_row.addWidget(self.chk_show_qt)
filter_row.addStretch(1)
layout.addLayout(filter_row)
self.chk_show_qt.toggled.connect(lambda checked: self.set_show_only_bec(not checked))
# Main tree widget
self.tree = QTreeWidget(self)
self.tree.setColumnCount(2)
self.tree.setHeaderLabels(["Property", "Value"])
self.tree.setAlternatingRowColors(True)
self.tree.setRootIsDecorated(False)
layout.addWidget(self.tree)
self._build()
def _class_chain(self):
chain = []
mo = self._target.metaObject()
while mo is not None:
chain.append(mo)
mo = mo.superClass()
return chain
def set_show_only_bec(self, flag: bool):
self._bec_only = flag
self._build()
def _set_equal_columns(self):
header = self.tree.header()
header.setSectionResizeMode(0, QHeaderView.Interactive)
header.setSectionResizeMode(1, QHeaderView.Interactive)
w = self.tree.viewport().width() or self.tree.width()
if w > 0:
half = max(1, w // 2)
self.tree.setColumnWidth(0, half)
self.tree.setColumnWidth(1, w - half)
def _build(self):
self.tree.clear()
for mo in self._class_chain():
class_name = mo.className()
if self._bec_only and not self._is_bec_metaobject(mo):
continue
group_item = QTreeWidgetItem(self.tree, [class_name])
group_item.setFirstColumnSpanned(True)
start = mo.propertyOffset()
end = mo.propertyCount()
for i in range(start, end):
prop = mo.property(i)
if (
not prop.isReadable()
or not prop.isWritable()
or not prop.isStored()
or not prop.isDesignable()
):
continue
name = prop.name()
if name == "objectName":
continue
value = self._target.property(name)
self._add_property_row(group_item, name, value, prop)
if group_item.childCount() == 0:
idx = self.tree.indexOfTopLevelItem(group_item)
self.tree.takeTopLevelItem(idx)
self.tree.expandAll()
QTimer.singleShot(0, self._set_equal_columns)
def _enum_int(self, obj) -> int:
return int(getattr(obj, "value", obj))
def _make_sizepolicy_editor(self, name: str, sp):
if not isinstance(sp, QSizePolicy):
return None
wrap = QWidget(self)
row = QHBoxLayout(wrap)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
h_combo = QComboBox(wrap)
v_combo = QComboBox(wrap)
hs = QSpinBox(wrap)
vs = QSpinBox(wrap)
for b in (hs, vs):
b.setRange(0, 16777215)
policies = [
(QSizePolicy.Fixed, "Fixed"),
(QSizePolicy.Minimum, "Minimum"),
(QSizePolicy.Maximum, "Maximum"),
(QSizePolicy.Preferred, "Preferred"),
(QSizePolicy.Expanding, "Expanding"),
(QSizePolicy.MinimumExpanding, "MinExpanding"),
(QSizePolicy.Ignored, "Ignored"),
]
for pol, text in policies:
h_combo.addItem(text, self._enum_int(pol))
v_combo.addItem(text, self._enum_int(pol))
def _set_current(combo, val):
idx = combo.findData(self._enum_int(val))
if idx >= 0:
combo.setCurrentIndex(idx)
_set_current(h_combo, sp.horizontalPolicy())
_set_current(v_combo, sp.verticalPolicy())
hs.setValue(sp.horizontalStretch())
vs.setValue(sp.verticalStretch())
def apply_changes():
hp = QSizePolicy.Policy(h_combo.currentData())
vp = QSizePolicy.Policy(v_combo.currentData())
nsp = QSizePolicy(hp, vp)
nsp.setHorizontalStretch(hs.value())
nsp.setVerticalStretch(vs.value())
self._target.setProperty(name, nsp)
h_combo.currentIndexChanged.connect(lambda _=None: apply_changes())
v_combo.currentIndexChanged.connect(lambda _=None: apply_changes())
hs.valueChanged.connect(lambda _=None: apply_changes())
vs.valueChanged.connect(lambda _=None: apply_changes())
row.addWidget(h_combo)
row.addWidget(v_combo)
row.addWidget(hs)
row.addWidget(vs)
return wrap
def _make_locale_editor(self, name: str, loc):
if not isinstance(loc, QLocale):
return None
wrap = QWidget(self)
row = QHBoxLayout(wrap)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
lang_combo = QComboBox(wrap)
country_combo = QComboBox(wrap)
for lang in QLocale.Language:
try:
lang_int = self._enum_int(lang)
except Exception:
continue
if lang_int < 0:
continue
name_txt = QLocale.languageToString(QLocale.Language(lang_int))
lang_combo.addItem(name_txt, lang_int)
def populate_countries():
country_combo.blockSignals(True)
country_combo.clear()
for terr in QLocale.Country:
try:
terr_int = self._enum_int(terr)
except Exception:
continue
if terr_int < 0:
continue
text = QLocale.countryToString(QLocale.Country(terr_int))
country_combo.addItem(text, terr_int)
cur_country = self._enum_int(loc.country())
idx = country_combo.findData(cur_country)
if idx >= 0:
country_combo.setCurrentIndex(idx)
country_combo.blockSignals(False)
cur_lang = self._enum_int(loc.language())
idx = lang_combo.findData(cur_lang)
if idx >= 0:
lang_combo.setCurrentIndex(idx)
populate_countries()
def apply_locale():
lang = QLocale.Language(int(lang_combo.currentData()))
country = QLocale.Country(int(country_combo.currentData()))
self._target.setProperty(name, QLocale(lang, country))
lang_combo.currentIndexChanged.connect(lambda _=None: populate_countries())
lang_combo.currentIndexChanged.connect(lambda _=None: apply_locale())
country_combo.currentIndexChanged.connect(lambda _=None: apply_locale())
row.addWidget(lang_combo)
row.addWidget(country_combo)
return wrap
def _make_icon_editor(self, name: str, icon):
btn = QPushButton(self)
btn.setText("Choose…")
if isinstance(icon, QIcon) and not icon.isNull():
btn.setIcon(icon)
def pick():
path, _ = QFileDialog.getOpenFileName(
self, "Select Icon", "", "Images (*.png *.jpg *.jpeg *.bmp *.svg)"
)
if path:
ic = QIcon(path)
self._target.setProperty(name, ic)
btn.setIcon(ic)
btn.clicked.connect(pick)
return btn
def _spin_pair(self, ints: bool = True):
box1 = QSpinBox(self) if ints else QDoubleSpinBox(self)
box2 = QSpinBox(self) if ints else QDoubleSpinBox(self)
if ints:
box1.setRange(-10_000_000, 10_000_000)
box2.setRange(-10_000_000, 10_000_000)
else:
for b in (box1, box2):
b.setDecimals(6)
b.setRange(-1e12, 1e12)
b.setSingleStep(0.1)
row = QHBoxLayout()
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
wrap = QWidget(self)
wrap.setLayout(row)
row.addWidget(box1)
row.addWidget(box2)
return wrap, box1, box2
def _spin_quad(self, ints: bool = True):
s = QSpinBox if ints else QDoubleSpinBox
boxes = [s(self) for _ in range(4)]
if ints:
for b in boxes:
b.setRange(-10_000_000, 10_000_000)
else:
for b in boxes:
b.setDecimals(6)
b.setRange(-1e12, 1e12)
b.setSingleStep(0.1)
row = QHBoxLayout()
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(4)
wrap = QWidget(self)
wrap.setLayout(row)
for b in boxes:
row.addWidget(b)
return wrap, boxes
def _make_font_editor(self, name: str, value):
btn = QPushButton(self)
if isinstance(value, QFont):
btn.setText(f"{value.family()}, {value.pointSize()}pt")
else:
btn.setText("Select font…")
def pick():
ok, font = QFontDialog.getFont(
value if isinstance(value, QFont) else QFont(), self, "Select Font"
)
if ok:
self._target.setProperty(name, font)
btn.setText(f"{font.family()}, {font.pointSize()}pt")
btn.clicked.connect(pick)
return btn
def _make_color_editor(self, initial: QColor, apply_cb):
btn = QPushButton(self)
if isinstance(initial, QColor):
btn.setText(initial.name())
btn.setStyleSheet(f"background:{initial.name()};")
else:
btn.setText("Select color…")
def pick():
col = QColorDialog.getColor(
initial if isinstance(initial, QColor) else QColor(), self, "Select Color"
)
if col.isValid():
apply_cb(col)
btn.setText(col.name())
btn.setStyleSheet(f"background:{col.name()};")
btn.clicked.connect(pick)
return btn
def _apply_palette_color(
self,
name: str,
pal: QPalette,
group: QPalette.ColorGroup,
role: QPalette.ColorRole,
col: QColor,
):
pal.setColor(group, role, col)
self._target.setProperty(name, pal)
def _make_palette_editor(self, name: str, pal: QPalette):
if not isinstance(pal, QPalette):
return None
wrap = QWidget(self)
row = QHBoxLayout(wrap)
row.setContentsMargins(0, 0, 0, 0)
group_combo = QComboBox(wrap)
role_combo = QComboBox(wrap)
pick_btn = self._make_color_editor(
pal.color(QPalette.Active, QPalette.WindowText),
lambda col: self._apply_palette_color(
name, pal, QPalette.Active, QPalette.WindowText, col
),
)
groups = [
(QPalette.Active, "Active"),
(QPalette.Inactive, "Inactive"),
(QPalette.Disabled, "Disabled"),
]
for g, label in groups:
group_combo.addItem(label, int(getattr(g, "value", g)))
roles = [
(QPalette.WindowText, "WindowText"),
(QPalette.Window, "Window"),
(QPalette.Base, "Base"),
(QPalette.AlternateBase, "AlternateBase"),
(QPalette.ToolTipBase, "ToolTipBase"),
(QPalette.ToolTipText, "ToolTipText"),
(QPalette.Text, "Text"),
(QPalette.Button, "Button"),
(QPalette.ButtonText, "ButtonText"),
(QPalette.BrightText, "BrightText"),
(QPalette.Highlight, "Highlight"),
(QPalette.HighlightedText, "HighlightedText"),
]
for r, label in roles:
role_combo.addItem(label, int(getattr(r, "value", r)))
def rewire_button():
g = QPalette.ColorGroup(int(group_combo.currentData()))
r = QPalette.ColorRole(int(role_combo.currentData()))
col = pal.color(g, r)
while row.count() > 2:
w = row.takeAt(2).widget()
if w:
w.deleteLater()
btn = self._make_color_editor(
col, lambda c: self._apply_palette_color(name, pal, g, r, c)
)
row.addWidget(btn)
group_combo.currentIndexChanged.connect(lambda _: rewire_button())
role_combo.currentIndexChanged.connect(lambda _: rewire_button())
row.addWidget(group_combo)
row.addWidget(role_combo)
row.addWidget(pick_btn)
return wrap
def _make_cursor_editor(self, name: str, value):
combo = QComboBox(self)
shapes = [
(Qt.ArrowCursor, "Arrow"),
(Qt.IBeamCursor, "IBeam"),
(Qt.WaitCursor, "Wait"),
(Qt.CrossCursor, "Cross"),
(Qt.UpArrowCursor, "UpArrow"),
(Qt.SizeAllCursor, "SizeAll"),
(Qt.PointingHandCursor, "PointingHand"),
(Qt.ForbiddenCursor, "Forbidden"),
(Qt.WhatsThisCursor, "WhatsThis"),
(Qt.BusyCursor, "Busy"),
]
current_shape = None
if isinstance(value, QCursor):
try:
enum_val = value.shape()
current_shape = int(getattr(enum_val, "value", enum_val))
except Exception:
current_shape = None
for shape, text in shapes:
combo.addItem(text, int(getattr(shape, "value", shape)))
if current_shape is not None:
idx = combo.findData(current_shape)
if idx >= 0:
combo.setCurrentIndex(idx)
def apply_index(i):
shape_val = int(combo.itemData(i))
self._target.setProperty(name, QCursor(Qt.CursorShape(shape_val)))
combo.currentIndexChanged.connect(apply_index)
return combo
def _add_property_row(self, parent: QTreeWidgetItem, name: str, value, prop):
item = QTreeWidgetItem(parent, [name, ""])
editor = self._make_editor(name, value, prop)
if editor is not None:
self.tree.setItemWidget(item, 1, editor)
else:
item.setText(1, repr(value))
def _is_bec_metaobject(self, mo) -> bool:
cname = mo.className()
for cls in type(self._target).mro():
if getattr(cls, "__name__", None) == cname:
mod = getattr(cls, "__module__", "")
return mod.startswith("bec_widgets")
return False
def _enum_text(self, meta_enum: QMetaEnum, value_int: int) -> str:
if not meta_enum.isFlag():
key = meta_enum.valueToKey(value_int)
return key.decode() if isinstance(key, (bytes, bytearray)) else (key or str(value_int))
parts = []
for i in range(meta_enum.keyCount()):
k = meta_enum.key(i)
v = meta_enum.value(i)
if value_int & v:
k = k.decode() if isinstance(k, (bytes, bytearray)) else k
parts.append(k)
return " | ".join(parts) if parts else "0"
def _enum_value_to_int(self, meta_enum: QMetaEnum, value) -> int:
try:
return int(value)
except Exception:
pass
v = getattr(value, "value", None)
if isinstance(v, (int,)):
return int(v)
n = getattr(value, "name", None)
if isinstance(n, str):
res = meta_enum.keyToValue(n)
if res != -1:
return int(res)
s = str(value)
parts = [p.strip() for p in s.replace(",", "|").split("|")]
keys = []
for p in parts:
if "." in p:
p = p.split(".")[-1]
keys.append(p)
keystr = "|".join(keys)
try:
res = meta_enum.keysToValue(keystr)
if res != -1:
return int(res)
except Exception:
pass
return 0
def _make_enum_editor(self, name: str, value, prop):
meta_enum = prop.enumerator()
current = self._enum_value_to_int(meta_enum, value)
if not meta_enum.isFlag():
combo = QComboBox(self)
for i in range(meta_enum.keyCount()):
key = meta_enum.key(i)
key = key.decode() if isinstance(key, (bytes, bytearray)) else key
combo.addItem(key, meta_enum.value(i))
idx = combo.findData(current)
if idx < 0:
txt = self._enum_text(meta_enum, current)
idx = combo.findText(txt)
combo.setCurrentIndex(max(idx, 0))
def apply_index(i):
v = combo.itemData(i)
self._target.setProperty(name, int(v))
combo.currentIndexChanged.connect(apply_index)
return combo
btn = QToolButton(self)
btn.setText(self._enum_text(meta_enum, current))
btn.setPopupMode(QToolButton.InstantPopup)
menu = QMenu(btn)
actions = []
for i in range(meta_enum.keyCount()):
key = meta_enum.key(i)
key = key.decode() if isinstance(key, (bytes, bytearray)) else key
act = menu.addAction(key)
act.setCheckable(True)
act.setChecked(bool(current & meta_enum.value(i)))
actions.append(act)
btn.setMenu(menu)
def apply_flags():
flags = 0
for i, act in enumerate(actions):
if act.isChecked():
flags |= meta_enum.value(i)
self._target.setProperty(name, int(flags))
btn.setText(self._enum_text(meta_enum, flags))
menu.triggered.connect(lambda _a: apply_flags())
return btn
def _make_editor(self, name: str, value, prop):
from qtpy.QtCore import QPoint, QPointF, QRect, QRectF, QSize, QSizeF
if prop.isEnumType():
return self._make_enum_editor(name, value, prop)
if isinstance(value, QColor):
return self._make_color_editor(value, lambda col: self._target.setProperty(name, col))
if isinstance(value, QFont):
return self._make_font_editor(name, value)
if isinstance(value, QPalette):
return self._make_palette_editor(name, value)
if isinstance(value, QCursor):
return self._make_cursor_editor(name, value)
if isinstance(value, QSizePolicy):
ed = self._make_sizepolicy_editor(name, value)
if ed is not None:
return ed
if isinstance(value, QLocale):
ed = self._make_locale_editor(name, value)
if ed is not None:
return ed
if isinstance(value, QIcon):
ed = self._make_icon_editor(name, value)
if ed is not None:
return ed
if isinstance(value, QSize):
wrap, w, h = self._spin_pair(ints=True)
w.setValue(value.width())
h.setValue(value.height())
w.valueChanged.connect(
lambda _: self._target.setProperty(name, QSize(w.value(), h.value()))
)
h.valueChanged.connect(
lambda _: self._target.setProperty(name, QSize(w.value(), h.value()))
)
return wrap
if isinstance(value, QSizeF):
wrap, w, h = self._spin_pair(ints=False)
w.setValue(value.width())
h.setValue(value.height())
w.valueChanged.connect(
lambda _: self._target.setProperty(name, QSizeF(w.value(), h.value()))
)
h.valueChanged.connect(
lambda _: self._target.setProperty(name, QSizeF(w.value(), h.value()))
)
return wrap
if isinstance(value, QPoint):
wrap, x, y = self._spin_pair(ints=True)
x.setValue(value.x())
y.setValue(value.y())
x.valueChanged.connect(
lambda _: self._target.setProperty(name, QPoint(x.value(), y.value()))
)
y.valueChanged.connect(
lambda _: self._target.setProperty(name, QPoint(x.value(), y.value()))
)
return wrap
if isinstance(value, QPointF):
wrap, x, y = self._spin_pair(ints=False)
x.setValue(value.x())
y.setValue(value.y())
x.valueChanged.connect(
lambda _: self._target.setProperty(name, QPointF(x.value(), y.value()))
)
y.valueChanged.connect(
lambda _: self._target.setProperty(name, QPointF(x.value(), y.value()))
)
return wrap
if isinstance(value, QRect):
wrap, boxes = self._spin_quad(ints=True)
for b, v in zip(boxes, (value.x(), value.y(), value.width(), value.height())):
b.setValue(v)
def apply_rect():
self._target.setProperty(
name,
QRect(boxes[0].value(), boxes[1].value(), boxes[2].value(), boxes[3].value()),
)
for b in boxes:
b.valueChanged.connect(lambda _=None: apply_rect())
return wrap
if isinstance(value, QRectF):
wrap, boxes = self._spin_quad(ints=False)
for b, v in zip(boxes, (value.x(), value.y(), value.width(), value.height())):
b.setValue(v)
def apply_rectf():
self._target.setProperty(
name,
QRectF(boxes[0].value(), boxes[1].value(), boxes[2].value(), boxes[3].value()),
)
for b in boxes:
b.valueChanged.connect(lambda _=None: apply_rectf())
return wrap
if isinstance(value, bool):
w = QCheckBox(self)
w.setChecked(bool(value))
w.toggled.connect(lambda v: self._target.setProperty(name, v))
return w
if isinstance(value, int) and not isinstance(value, bool):
w = QSpinBox(self)
w.setRange(-10_000_000, 10_000_000)
w.setValue(int(value))
w.valueChanged.connect(lambda v: self._target.setProperty(name, v))
return w
if isinstance(value, float):
w = QDoubleSpinBox(self)
w.setDecimals(6)
w.setRange(-1e12, 1e12)
w.setSingleStep(0.1)
w.setValue(float(value))
w.valueChanged.connect(lambda v: self._target.setProperty(name, v))
return w
if isinstance(value, str):
w = QLineEdit(self)
w.setText(value)
w.editingFinished.connect(lambda: self._target.setProperty(name, w.text()))
return w
return None
class DemoApp(QWidget): # pragma: no cover:
def __init__(self):
super().__init__()
layout = QHBoxLayout(self)
# Create a BECWidget instance example
waveform = self.create_waveform()
# property editor for the BECWidget
property_editor = PropertyEditor(waveform, show_only_bec=True)
layout.addWidget(waveform)
layout.addWidget(property_editor)
def create_waveform(self):
"""Create a new waveform widget."""
from bec_widgets.widgets.plots.waveform.waveform import Waveform
waveform = Waveform(parent=self)
waveform.title = "New Waveform"
waveform.x_label = "X Axis"
waveform.y_label = "Y Axis"
return waveform
if __name__ == "__main__": # pragma: no cover:
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
demo = DemoApp()
demo.setWindowTitle("Property Editor Demo")
demo.resize(1200, 800)
demo.show()
sys.exit(app.exec())

View File

@@ -112,7 +112,9 @@ class DeviceInputBase(BECWidget):
WidgetIO.set_value(widget=self, value=device)
self.config.default = device
else:
logger.warning(f"Device {device} is not in the filtered selection.")
logger.warning(
f"Device {device} is not in the filtered selection of {self}: {self.devices}."
)
@SafeSlot()
def update_devices_from_filters(self):
@@ -131,7 +133,8 @@ class DeviceInputBase(BECWidget):
# Filter based on readout priority
devs = [dev for dev in devs if self._check_readout_filter(dev)]
self.devices = [device.name for device in devs]
self.set_device(current_device)
if current_device != "":
self.set_device(current_device)
@SafeSlot(list)
def set_available_devices(self, devices: list[str]):

View File

@@ -0,0 +1,998 @@
from __future__ import annotations
from qtpy.QtCore import QSize, QSortFilterProxyModel, Qt
from qtpy.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QFormLayout,
QFrame,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMessageBox,
QPushButton,
QScrollArea,
QSizePolicy,
QSplitter,
QTableWidget,
QTableWidgetItem,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from thefuzz import fuzz
from bec_widgets.utils.bec_table import BECTable
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
class CheckBoxCenterWidget(QWidget):
"""Widget to center a checkbox in a table cell."""
def __init__(self, checked=False, parent=None):
super().__init__(parent)
layout = QHBoxLayout(self)
layout.setAlignment(Qt.AlignCenter)
layout.setContentsMargins(4, 0, 4, 0) # Reduced margins for more compact layout
self.checkbox = QCheckBox()
self.checkbox.setChecked(checked)
self.checkbox.setEnabled(False) # Read-only
# Store the value for sorting
self.value = checked
layout.addWidget(self.checkbox)
class TextLabelWidget(QWidget):
"""Widget to display text with word wrapping in a table cell."""
def __init__(self, text="", parent=None):
super().__init__(parent)
# Use a layout with minimal margins to maximize text display area
layout = QVBoxLayout(self)
layout.setContentsMargins(2, 2, 2, 2)
layout.setSpacing(0)
# Create label with word wrap enabled
self.label = QLabel(text)
self.label.setWordWrap(True)
self.label.setTextInteractionFlags(Qt.TextSelectableByMouse)
self.label.setAlignment(Qt.AlignLeft | Qt.AlignTop) # Align to top-left
# Make sure label expands to fill available space
self.label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
# Store the text value for sorting
self.value = text
layout.addWidget(self.label)
# Make sure the widget itself uses an expanding size policy
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.MinimumExpanding)
# Ensure we have a reasonable height to start with
# This helps ensure text is visible before resizing calculations
min_height = 40 if text else 20
self.setMinimumHeight(min_height)
def setText(self, text):
"""Set the text of the label."""
self.label.setText(text)
self.value = text
# Trigger layout update
self.updateGeometry()
def sizeHint(self):
"""Provide a size hint based on the text content."""
# Get the width of our container (usually the table cell)
width = self.width() or 300
# If text is empty, return minimal size
if not self.value:
return QSize(width, 20)
# Calculate height for wrapped text
font_metrics = self.label.fontMetrics()
# Estimate how much space the text will need when wrapped
text_rect = font_metrics.boundingRect(
0,
0,
width - 10,
1000, # Width constraint, virtually unlimited height
Qt.TextWordWrap,
self.value,
)
# Add some padding
height = text_rect.height() + 8
return QSize(width, max(30, height))
def resizeEvent(self, event):
"""Handle resize events to ensure text is properly displayed."""
super().resizeEvent(event)
# When resized (especially width change), update layout to ensure text wrapping works
self.label.updateGeometry()
self.updateGeometry()
class SortableTableWidgetItem(QTableWidgetItem):
"""Table widget item that enables proper sorting for different data types."""
def __lt__(self, other):
"""Compare items for sorting."""
if self.text() == "Yes" and other.text() == "No":
return True
elif self.text() == "No" and other.text() == "Yes":
return False
else:
return self.text().lower() < other.text().lower()
class DeviceTagsWidget(BECWidget, QWidget):
"""Widget to display devices grouped by their tags in containers."""
def __init__(self, parent=None):
super().__init__(parent=parent)
self.layout = QVBoxLayout(self)
self.setLayout(self.layout)
# Title
self.title_label = QLabel("Device Tags")
self.title_label.setStyleSheet("font-weight: bold; font-size: 14px;")
self.layout.addWidget(self.title_label)
# Search bar for tags
self.search_layout = QHBoxLayout()
self.search_label = QLabel("Search:")
self.search_input = QLineEdit()
self.search_input.setPlaceholderText("Filter tags...")
self.search_input.setClearButtonEnabled(True)
self.search_input.textChanged.connect(self.filter_tags)
self.search_layout.addWidget(self.search_label)
self.search_layout.addWidget(self.search_input)
self.layout.addLayout(self.search_layout)
# Create a scroll area for tag containers
self.scroll_area = QScrollArea()
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
# Create a widget to hold all tag containers
self.scroll_widget = QWidget()
self.scroll_layout = QVBoxLayout(self.scroll_widget)
self.scroll_layout.setSpacing(10)
self.scroll_layout.setContentsMargins(5, 5, 5, 5)
self.scroll_layout.setAlignment(Qt.AlignTop)
self.scroll_area.setWidget(self.scroll_widget)
self.layout.addWidget(self.scroll_area)
# Initialize with empty data
self.all_devices = []
self.active_devices = []
self.device_tags = {} # Maps tag names to lists of device names
self.tag_containers = {} # Maps tag names to their container widgets
# Load initial data
self.update_tags()
def update_tags(self):
"""Update the tags containers with current device information."""
try:
# Get device config
config = self.client.device_manager._get_redis_device_config()
# Clear current data
self.all_devices = []
self.active_devices = []
self.device_tags = {}
# Process device config
for device_info in config:
device_name = device_info.get("name", "Unknown")
self.all_devices.append(device_name)
# Add to active devices if enabled
if device_info.get("enabled", False):
self.active_devices.append(device_name)
# Process device tags
tags = device_info.get("deviceTags", [])
for tag in tags:
if tag not in self.device_tags:
self.device_tags[tag] = []
self.device_tags[tag].append(device_name)
# Update the tag containers
self.populate_tag_containers()
except Exception as e:
ErrorPopupUtility().show_error_message(
"Device Tags Error", f"Error updating device tags: {str(e)}", self
)
def populate_tag_containers(self):
"""Populate the containers with current tag and device data."""
# Save current filter before clearing
current_filter = self.search_input.text() if hasattr(self, "search_input") else ""
# Clear existing containers
for i in reversed(range(self.scroll_layout.count())):
widget = self.scroll_layout.itemAt(i).widget()
if widget:
widget.setParent(None)
widget.deleteLater()
self.tag_containers = {}
# Add tag containers
for tag, devices in sorted(self.device_tags.items()):
# Create container frame for this tag
container = QFrame()
container.setFrameStyle(QFrame.StyledPanel | QFrame.Raised)
container.setStyleSheet(
"QFrame { background-color: palette(window); border: 1px solid palette(mid); border-radius: 4px; }"
)
container_layout = QVBoxLayout(container)
container_layout.setContentsMargins(10, 10, 10, 10)
# Add tag header with status indicator
header_layout = QHBoxLayout()
# Tag name label
tag_label = QLabel(tag)
tag_label.setStyleSheet("font-weight: bold;")
header_layout.addWidget(tag_label)
# Spacer to push status to the right
header_layout.addStretch()
# Status indicator
all_devices_count = len(devices)
active_devices_count = sum(1 for d in devices if d in self.active_devices)
if active_devices_count == 0:
status_text = "None"
status_color = "red"
elif active_devices_count == all_devices_count:
status_text = "All"
status_color = "green"
else:
status_text = f"{active_devices_count}/{all_devices_count}"
status_color = "orange"
status_label = QLabel(status_text)
status_label.setStyleSheet(f"color: {status_color}; font-weight: bold;")
header_layout.addWidget(status_label)
container_layout.addLayout(header_layout)
# Add divider line
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
container_layout.addWidget(line)
# Add device list
device_list = QListWidget()
device_list.setAlternatingRowColors(True)
device_list.setMaximumHeight(150) # Limit height
# Add devices to the list
for device_name in sorted(devices):
item = QListWidgetItem(device_name)
if device_name in self.active_devices:
item.setForeground(Qt.green)
else:
item.setForeground(Qt.red)
device_list.addItem(item)
container_layout.addWidget(device_list)
# Add to the scroll layout
self.scroll_layout.addWidget(container)
self.tag_containers[tag] = container
# Add a stretch at the end to push all containers to the top
self.scroll_layout.addStretch()
# Reapply filter if there was one
if current_filter:
self.filter_tags(current_filter)
@SafeSlot(str)
def filter_tags(self, text):
"""Filter the tag containers based on search text."""
if not hasattr(self, "tag_containers"):
return
text = text.lower()
# Show/hide tag containers based on filter
for tag, container in self.tag_containers.items():
if not text or text in tag.lower():
# Tag matches filter
container.show()
else:
# Check if any device in this tag matches
matches = False
for device in self.device_tags.get(tag, []):
if text in device.lower():
matches = True
break
container.setVisible(matches)
@SafeSlot()
def add_devices_by_tag(self):
"""Add devices with the selected tags to the active configuration."""
# This would be implemented for drag-and-drop in the future
@SafeSlot()
def remove_devices_by_tag(self):
"""Remove devices with the selected tags from the active configuration."""
# This would be implemented for drag-and-drop in the future
class DeviceManager(BECWidget, QWidget):
"""Widget to display the current device configuration in a table."""
def __init__(self, parent=None):
super().__init__(parent=parent)
# Main layout for the entire widget
self.main_layout = QHBoxLayout(self)
self.setLayout(self.main_layout)
# Create a splitter to hold the device tags widget and device table
self.splitter = QSplitter(Qt.Horizontal)
# Create device tags widget
self.device_tags_widget = DeviceTagsWidget(self)
self.splitter.addWidget(self.device_tags_widget)
# Create container for device table and its controls
self.table_container = QWidget()
self.layout = QVBoxLayout(self.table_container)
# Create search bar
self.search_layout = QHBoxLayout()
self.search_label = QLabel("Search:")
self.search_input = QLineEdit()
self.search_input.setPlaceholderText(
"Filter devices (approximate matching)..."
) # Default to fuzzy search
self.search_input.setClearButtonEnabled(True)
self.search_input.textChanged.connect(self.filter_devices)
self.search_layout.addWidget(self.search_label)
self.search_layout.addWidget(self.search_input)
# Add exact match toggle
self.fuzzy_toggle_layout = QHBoxLayout()
self.fuzzy_toggle_label = QLabel("Exact Match:")
self.fuzzy_toggle = ToggleSwitch()
self.fuzzy_toggle.setChecked(False) # Default to fuzzy search (toggle OFF)
self.fuzzy_toggle.stateChanged.connect(self.on_fuzzy_toggle_changed)
self.fuzzy_toggle.setToolTip(
"Toggle between approximate matching (OFF) and exact matching (ON)"
)
self.fuzzy_toggle_label.setToolTip(
"Toggle between approximate matching (OFF) and exact matching (ON)"
)
self.fuzzy_toggle_layout.addWidget(self.fuzzy_toggle_label)
self.fuzzy_toggle_layout.addWidget(self.fuzzy_toggle)
self.fuzzy_toggle_layout.addStretch()
# Add both search components to the layout
self.search_controls = QHBoxLayout()
self.search_controls.addLayout(self.search_layout)
self.search_controls.addSpacing(20) # Add some space between the search box and toggle
self.search_controls.addLayout(self.fuzzy_toggle_layout)
self.layout.addLayout(self.search_controls)
# Create table widget
self.device_table = BECTable()
self.device_table.setEditTriggers(QTableWidget.NoEditTriggers) # Make table read-only
self.device_table.setSelectionBehavior(QTableWidget.SelectRows)
self.device_table.setAlternatingRowColors(True)
self.layout.addWidget(self.device_table)
# Connect custom sorting handler
self.device_table.horizontalHeader().sectionClicked.connect(self.handle_header_click)
self.current_sort_section = 0
self.current_sort_order = Qt.AscendingOrder
# Make table resizable
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.device_table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
# Don't stretch the last section to prevent it from changing width
self.device_table.horizontalHeader().setStretchLastSection(False)
self.device_table.verticalHeader().setVisible(False)
# Set up initial headers
self.headers = [
"Name",
"Device Class",
"Readout Priority",
"Enabled",
"Read Only",
"Documentation",
]
self.device_table.setColumnCount(len(self.headers))
self.device_table.setHorizontalHeaderLabels(self.headers)
# Set initial column resize modes
header = self.device_table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeToContents) # Name
header.setSectionResizeMode(1, QHeaderView.ResizeToContents) # Device Class
header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Readout Priority
header.setSectionResizeMode(3, QHeaderView.Fixed) # Enabled
header.setSectionResizeMode(4, QHeaderView.Fixed) # Read Only
header.setSectionResizeMode(5, QHeaderView.Stretch) # Documentation
# Connect resize signal to adjust row heights when table is resized
self.device_table.horizontalHeader().sectionResized.connect(self.on_table_resized)
# Set fixed width for checkbox columns
self.device_table.setColumnWidth(3, 70) # Enabled column
self.device_table.setColumnWidth(4, 70) # Read Only column
# Ensure column widths stay fixed
header.setMinimumSectionSize(70)
header.setDefaultSectionSize(100)
# Enable sorting by clicking column headers
self.device_table.setSortingEnabled(False) # We'll handle sorting manually
self.device_table.horizontalHeader().setSortIndicatorShown(True)
self.device_table.horizontalHeader().setSectionsClickable(True)
# Add buttons for adding/removing devices
self.button_layout = QHBoxLayout()
# Add device button
self.add_device_button = QPushButton("Add Device")
self.add_device_button.clicked.connect(self.add_device)
self.button_layout.addWidget(self.add_device_button)
# Remove device button
self.remove_device_button = QPushButton("Remove Device")
self.remove_device_button.clicked.connect(self.remove_device)
self.button_layout.addWidget(self.remove_device_button)
# Add buttons to main layout
self.layout.addLayout(self.button_layout)
# Add the table container to the splitter
self.splitter.addWidget(self.table_container)
# Set initial sizes (30% for tags, 70% for table)
self.splitter.setSizes([300, 700])
# Add the splitter to the main layout
self.main_layout.addWidget(self.splitter)
# Connect signals between widgets
self.connect_signals()
# Load initial data
self.update_device_table()
def connect_signals(self):
"""Connect signals between the device table and tags widget."""
# Connect add devices by tag button to update the table
if hasattr(self.device_tags_widget, "add_tag_button"):
self.device_tags_widget.add_tag_button.clicked.connect(self.update_device_table)
# Connect remove devices by tag button to update the table
if hasattr(self.device_tags_widget, "remove_tag_button"):
self.device_tags_widget.remove_tag_button.clicked.connect(self.update_device_table)
@SafeSlot(int, int, int)
def on_table_resized(self, column, old_width, new_width):
"""Handle table column resize events to readjust row heights for text wrapping."""
# Only handle resizes of the documentation column
if column == 5:
# Update all rows with TextLabelWidgets in the documentation column
for row in range(self.device_table.rowCount()):
doc_widget = self.device_table.cellWidget(row, 5)
if doc_widget and isinstance(doc_widget, TextLabelWidget):
# Trigger recalculation of text wrapping
doc_widget.updateGeometry()
# Force the table to recalculate row heights
if doc_widget.value:
# Get text metrics
font_metrics = doc_widget.label.fontMetrics()
# Calculate new text height with word wrap
text_rect = font_metrics.boundingRect(
0,
0,
new_width - 10,
2000, # New width constraint
Qt.TextWordWrap,
doc_widget.value,
)
# Update row height
row_height = text_rect.height() + 16
self.device_table.setRowHeight(row, max(40, row_height))
@SafeSlot()
def update_device_table(self):
"""Update the device table with the current device configuration."""
try:
# Get device config (always a list of dictionaries)
config = self.client.device_manager._get_redis_device_config()
# Clear existing rows
self.device_table.setRowCount(0)
# Add devices to the table
for device_info in config:
row_position = self.device_table.rowCount()
self.device_table.insertRow(row_position)
# Set device name
self.device_table.setItem(
row_position, 0, SortableTableWidgetItem(device_info.get("name", "Unknown"))
)
# Set device class
device_class = device_info.get("deviceClass", "Unknown")
self.device_table.setItem(row_position, 1, SortableTableWidgetItem(device_class))
# Set readout priority
readout_priority = device_info.get("readoutPriority", "Unknown")
self.device_table.setItem(
row_position, 2, SortableTableWidgetItem(readout_priority)
)
# Set enabled status as checkbox
enabled_checkbox = CheckBoxCenterWidget(device_info.get("enabled", False))
self.device_table.setCellWidget(row_position, 3, enabled_checkbox)
# Set read-only status as checkbox
readonly_checkbox = CheckBoxCenterWidget(device_info.get("readOnly", False))
self.device_table.setCellWidget(row_position, 4, readonly_checkbox)
# Set documentation using text label widget with word wrap
documentation = device_info.get("documentation", "")
doc_widget = TextLabelWidget(documentation)
self.device_table.setCellWidget(row_position, 5, doc_widget)
# First, ensure the table is updated to show the new widgets
self.device_table.viewport().update()
# Force a layout update to get proper sizes
self.device_table.resizeRowsToContents()
# Then adjust row heights with better calculation for wrapped text
for row in range(self.device_table.rowCount()):
doc_widget = self.device_table.cellWidget(row, 5)
if doc_widget and isinstance(doc_widget, TextLabelWidget):
text = doc_widget.value
if text:
# Get the column width
col_width = self.device_table.columnWidth(5)
# Calculate appropriate height for the text
font_metrics = doc_widget.label.fontMetrics()
# Calculate text rectangle with word wrap
text_rect = font_metrics.boundingRect(
0,
0,
col_width - 10,
2000, # Width constraint with large height
Qt.TextWordWrap,
text,
)
# Set row height with additional padding
row_height = text_rect.height() + 16
self.device_table.setRowHeight(row, max(40, row_height))
# Update the widget to reflect the new size
doc_widget.updateGeometry()
# Apply current sort if any
if hasattr(self, "current_sort_section") and self.current_sort_section >= 0:
self.sort_table(self.current_sort_section, self.current_sort_order)
self.device_table.horizontalHeader().setSortIndicator(
self.current_sort_section, self.current_sort_order
)
# Reset the filter to make sure search works with new data
if hasattr(self, "search_input"):
current_filter = self.search_input.text()
if current_filter:
self.filter_devices(current_filter)
# Update the device tags widget
self.device_tags_widget.update_tags()
except Exception as e:
ErrorPopupUtility().show_error_message(
"Device Manager Error", f"Error updating device table: {str(e)}", self
)
@SafeSlot(bool)
def on_fuzzy_toggle_changed(self, enabled):
"""
Handle exact match toggle state change.
When toggle is ON (enabled=True): Use exact matching
When toggle is OFF (enabled=False): Use fuzzy/approximate matching
"""
# Update search mode label
if hasattr(self, "search_input"):
# Store original stylesheet to restore it later
original_style = self.search_input.styleSheet()
# Set placeholder text based on mode
if enabled: # Toggle ON = Exact match
self.search_input.setPlaceholderText("Filter devices (exact match)...")
print("Toggle switched ON: Using EXACT match mode")
else: # Toggle OFF = Approximate/fuzzy match
self.search_input.setPlaceholderText("Filter devices (approximate matching)...")
print("Toggle switched OFF: Using FUZZY match mode")
# Visual feedback - briefly highlight the search box with appropriate color
highlight_color = "#3498db" # Blue for feedback
self.search_input.setStyleSheet(f"border: 2px solid {highlight_color};")
# Create a one-time timer to restore the original style after a short delay
from qtpy.QtCore import QTimer
QTimer.singleShot(500, lambda: self.search_input.setStyleSheet(original_style))
# Log the toggle state for debugging
print(
f"Search mode changed: Exact match = {enabled}, Toggle isChecked = {self.fuzzy_toggle.isChecked()}"
)
# When toggle changes, reapply current search with new mode
current_text = self.search_input.text()
# Always reapply the filter, even if text is empty
# This ensures all rows are properly shown/hidden based on the new mode
self.filter_devices(current_text)
@SafeSlot(str)
def filter_devices(self, text):
"""Filter devices in the table based on exact or approximate matching."""
# Always show all rows when search is empty, regardless of match mode
if not text:
for row in range(self.device_table.rowCount()):
self.device_table.setRowHidden(row, False)
return
# Get current search mode
# When toggle is ON, we use exact match
# When toggle is OFF, we use fuzzy/approximate match
use_exact_match = hasattr(self, "fuzzy_toggle") and self.fuzzy_toggle.isChecked()
# Debug print to verify which mode is being used
print(f"Filtering with exact match: {use_exact_match}, search text: '{text}'")
# Threshold for fuzzy matching (0-100, higher is more strict)
threshold = 80
# Prepare search text (lowercase for case-insensitive search)
search_text = text.lower()
# Count of matched rows for feedback (but avoid double-counting)
visible_rows = 0
total_rows = self.device_table.rowCount()
# Filter rows using either exact or approximate matching
for row in range(total_rows):
row_visible = False
# Check name and device class columns (0 and 1)
for col in [0, 1]: # Name and Device Class columns
item = self.device_table.item(row, col)
if not item:
continue
cell_text = item.text().lower()
if use_exact_match:
# EXACT MATCH: Simple substring check
if search_text in cell_text:
row_visible = True
break
else:
# FUZZY MATCH: Use approximate matching
match_ratio = fuzz.partial_ratio(search_text, cell_text)
if match_ratio >= threshold:
row_visible = True
break
# Hide or show this row
self.device_table.setRowHidden(row, not row_visible)
# Count visible rows for potential feedback
if row_visible:
visible_rows += 1
@SafeSlot(int)
def handle_header_click(self, section):
"""Handle column header click to sort the table."""
# Toggle sort order if clicking the same section
if section == self.current_sort_section:
self.current_sort_order = (
Qt.DescendingOrder
if self.current_sort_order == Qt.AscendingOrder
else Qt.AscendingOrder
)
else:
self.current_sort_section = section
self.current_sort_order = Qt.AscendingOrder
# Update sort indicator
self.device_table.horizontalHeader().setSortIndicator(
self.current_sort_section, self.current_sort_order
)
# Perform the sort
self.sort_table(section, self.current_sort_order)
def sort_table(self, column, order):
"""Sort the table by the specified column and order."""
row_count = self.device_table.rowCount()
if row_count <= 1:
return # Nothing to sort
# Collect all rows for sorting
rows_data = []
for row in range(row_count):
# Create a safe copy of the row data
row_data = {}
row_data["items"] = []
row_data["widgets"] = []
row_data["hidden"] = self.device_table.isRowHidden(row)
row_data["sort_key"] = None
# Extract sort key for this row
if column in [3, 4]: # Checkbox columns
widget = self.device_table.cellWidget(row, column)
if widget and hasattr(widget, "value"):
row_data["sort_key"] = widget.value
else:
row_data["sort_key"] = False
else: # Text columns
item = self.device_table.item(row, column)
if item:
row_data["sort_key"] = item.text().lower()
else:
row_data["sort_key"] = ""
# Collect all items and widgets in the row
for col in range(self.device_table.columnCount()):
if col in [3, 4]: # Checkbox columns
widget = self.device_table.cellWidget(row, col)
if widget:
# Store the widget value to recreate it
is_checked = False
if hasattr(widget, "value"):
is_checked = widget.value
elif hasattr(widget, "checkbox"):
is_checked = widget.checkbox.isChecked()
row_data["widgets"].append((col, "checkbox", is_checked))
elif col == 5: # Documentation column with TextLabelWidget
widget = self.device_table.cellWidget(row, col)
if widget and isinstance(widget, TextLabelWidget):
text = widget.value
row_data["widgets"].append((col, "textlabel", text))
else:
row_data["widgets"].append((col, "textlabel", ""))
else:
item = self.device_table.item(row, col)
if item:
row_data["items"].append((col, item.text()))
else:
row_data["items"].append((col, ""))
rows_data.append(row_data)
# Sort the rows
reverse = order == Qt.DescendingOrder
sorted_rows = sorted(rows_data, key=lambda x: x["sort_key"], reverse=reverse)
# Rebuild the table with sorted data
self.device_table.setUpdatesEnabled(False) # Disable updates while rebuilding
# Clear and rebuild the table
self.device_table.clearContents()
self.device_table.setRowCount(row_count)
for row, row_data in enumerate(sorted_rows):
# Add text items
for col, text in row_data["items"]:
self.device_table.setItem(row, col, SortableTableWidgetItem(text))
# Add widgets
for col, widget_type, value in row_data["widgets"]:
if widget_type == "checkbox":
checkbox = CheckBoxCenterWidget(value)
self.device_table.setCellWidget(row, col, checkbox)
elif widget_type == "textlabel":
text_label = TextLabelWidget(value)
self.device_table.setCellWidget(row, col, text_label)
# Restore hidden state
self.device_table.setRowHidden(row, row_data["hidden"])
self.device_table.setUpdatesEnabled(True) # Re-enable updates
@SafeSlot()
def show_add_device_dialog(self):
"""Show the dialog for adding a new device."""
# Call the add_device method to handle the dialog and logic
self.add_device()
@SafeSlot()
def add_device(self):
"""Simulate adding a new device to the configuration."""
try:
# Create and show the add device dialog
dialog = AddDeviceDialog(self)
if dialog.exec():
# Get device config from dialog
device_config = dialog.get_device_config()
device_name = device_config.get("name")
# Print the action that would be taken (simulation only)
print(f"Would add device: {device_name} with config: {device_config}")
# Show simulation message
QMessageBox.information(
self,
"Device Addition Simulated",
f"Would add device: {device_name} (simulation only)",
)
# Update the device tags widget
self.device_tags_widget.update_tags()
except Exception as e:
ErrorPopupUtility().show_error_message(
"Device Manager Error", f"Error in add device simulation: {str(e)}", self
)
@SafeSlot()
def remove_device(self):
"""Simulate removing selected device(s) from the configuration."""
selected_rows = self.device_table.selectionModel().selectedRows()
if not selected_rows:
QMessageBox.information(self, "No Selection", "Please select a device to remove.")
return
# Confirm deletion
device_count = len(selected_rows)
message = f"Are you sure you want to remove {device_count} device{'s' if device_count > 1 else ''}?"
confirmation = QMessageBox.question(
self, "Confirm Removal", message, QMessageBox.Yes | QMessageBox.No
)
if confirmation == QMessageBox.Yes:
try:
# Get device names from selected rows
device_names = []
for index in selected_rows:
row = index.row()
device_name = self.device_table.item(row, 0).text()
device_names.append(device_name)
# Print removal action instead of actual removal
print(f"Would remove devices: {device_names}")
# Show simulation message
QMessageBox.information(
self,
"Device Removal Simulated",
f"Would remove {device_count} device{'s' if device_count > 1 else ''} (simulation only)",
)
# Update the device tags widget
self.device_tags_widget.update_tags()
except Exception as e:
ErrorPopupUtility().show_error_message(
"Device Manager Error", f"Error in remove device simulation: {str(e)}", self
)
class AddDeviceDialog(QDialog):
"""Dialog for adding a new device to the configuration."""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Add New Device")
self.setMinimumWidth(400)
# Create layout
self.layout = QVBoxLayout(self)
self.form_layout = QFormLayout()
# Device name
self.name_input = QLineEdit()
self.form_layout.addRow("Device Name:", self.name_input)
# Device class
self.device_class_input = QLineEdit()
self.device_class_input.setText("ophyd_devices.SimPositioner")
self.form_layout.addRow("Device Class:", self.device_class_input)
# Readout priority
self.readout_priority_combo = QComboBox()
self.readout_priority_combo.addItems(["baseline", "monitored", "async", "on_request"])
self.form_layout.addRow("Readout Priority:", self.readout_priority_combo)
# Enabled checkbox
self.enabled_checkbox = QCheckBox()
self.enabled_checkbox.setChecked(True)
self.form_layout.addRow("Enabled:", self.enabled_checkbox)
# Read-only checkbox
self.readonly_checkbox = QCheckBox()
self.form_layout.addRow("Read Only:", self.readonly_checkbox)
# Documentation text
self.documentation_input = QLineEdit()
self.form_layout.addRow("Documentation:", self.documentation_input)
# Add form to layout
self.layout.addLayout(self.form_layout)
# Add buttons
self.button_layout = QHBoxLayout()
self.cancel_button = QPushButton("Cancel")
self.cancel_button.clicked.connect(self.reject)
self.add_button = QPushButton("Add Device")
self.add_button.clicked.connect(self.accept)
self.button_layout.addWidget(self.cancel_button)
self.button_layout.addWidget(self.add_button)
self.layout.addLayout(self.button_layout)
def get_device_config(self):
"""Get the device configuration from the dialog."""
return {
"name": self.name_input.text(),
"deviceClass": self.device_class_input.text(),
"readoutPriority": self.readout_priority_combo.currentText(),
"enabled": self.enabled_checkbox.isChecked(),
"readOnly": self.readonly_checkbox.isChecked(),
"documentation": self.documentation_input.text(),
"deviceConfig": {}, # Empty config for now
}
if __name__ == "__main__":
from qtpy.QtWidgets import QApplication
app = QApplication([])
window = DeviceManager()
window.show()
app.exec_()

View File

@@ -1,6 +1,7 @@
from typing import Literal
import qtmonaco
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
@@ -12,11 +13,14 @@ class MonacoWidget(BECWidget, QWidget):
A simple Monaco editor widget
"""
text_changed = Signal(str)
PLUGIN = True
ICON_NAME = "code"
USER_ACCESS = [
"set_text",
"get_text",
"insert_text",
"delete_line",
"set_language",
"get_language",
"set_theme",
@@ -25,6 +29,9 @@ class MonacoWidget(BECWidget, QWidget):
"set_cursor",
"current_cursor",
"set_minimap_enabled",
"set_vim_mode_enabled",
"set_lsp_header",
"get_lsp_header",
]
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
@@ -36,6 +43,7 @@ class MonacoWidget(BECWidget, QWidget):
self.editor = qtmonaco.Monaco(self)
layout.addWidget(self.editor)
self.setLayout(layout)
self.editor.text_changed.connect(self.text_changed.emit)
self.editor.initialized.connect(self.apply_theme)
def apply_theme(self, theme: str | None = None) -> None:
@@ -65,6 +73,26 @@ class MonacoWidget(BECWidget, QWidget):
"""
return self.editor.get_text()
def insert_text(self, text: str, line: int | None = None, column: int | None = None) -> None:
"""
Insert text at the current cursor position or at a specified line and column.
Args:
text (str): The text to insert.
line (int, optional): The line number (1-based) to insert the text at. Defaults to None.
column (int, optional): The column number (1-based) to insert the text at. Defaults to None.
"""
self.editor.insert_text(text, line, column)
def delete_line(self, line: int | None = None) -> None:
"""
Delete a line in the Monaco editor.
Args:
line (int, optional): The line number (1-based) to delete. If None, the current line will be deleted.
"""
self.editor.delete_line(line)
def set_cursor(
self,
line: int,
@@ -154,6 +182,34 @@ class MonacoWidget(BECWidget, QWidget):
"""
self.editor.clear_highlighted_lines()
def set_vim_mode_enabled(self, enabled: bool) -> None:
"""
Enable or disable Vim mode in the Monaco editor.
Args:
enabled (bool): If True, Vim mode will be enabled; otherwise, it will be disabled.
"""
self.editor.set_vim_mode_enabled(enabled)
def set_lsp_header(self, header: str) -> None:
"""
Set the LSP (Language Server Protocol) header for the Monaco editor.
The header is used to provide context for language servers but is not displayed in the editor.
Args:
header (str): The LSP header to set.
"""
self.editor.set_lsp_header(header)
def get_lsp_header(self) -> str:
"""
Get the current LSP header set in the Monaco editor.
Returns:
str: The LSP header.
"""
return self.editor.get_lsp_header()
if __name__ == "__main__": # pragma: no cover
qapp = QApplication([])

View File

@@ -6,11 +6,12 @@ import time
from bec_lib.logger import bec_logger
from louie.saferef import safe_ref
from qtpy.QtCore import QUrl, qInstallMessageHandler
from qtpy.QtCore import QTimer, QUrl, Signal, qInstallMessageHandler
from qtpy.QtWebEngineWidgets import QWebEnginePage, QWebEngineView
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty
logger = bec_logger.logger
@@ -165,11 +166,16 @@ class WebConsole(BECWidget, QWidget):
A simple widget to display a website
"""
_js_callback = Signal(bool)
initialized = Signal()
PLUGIN = True
ICON_NAME = "terminal"
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self._startup_cmd = "bec --nogui"
self._is_initialized = False
_web_console_registry.register(self)
self._token = _web_console_registry._token
layout = QVBoxLayout()
@@ -181,6 +187,48 @@ class WebConsole(BECWidget, QWidget):
layout.addWidget(self.browser)
self.setLayout(layout)
self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}"))
self._startup_timer = QTimer()
self._startup_timer.setInterval(500)
self._startup_timer.timeout.connect(self._check_page_ready)
self._startup_timer.start()
self._js_callback.connect(self._on_js_callback)
def _check_page_ready(self):
"""
Check if the page is ready and stop the timer if it is.
"""
if self.page.isLoading():
return
self.page.runJavaScript("window.term !== undefined", self._js_callback.emit)
def _on_js_callback(self, ready: bool):
"""
Callback for when the JavaScript is ready.
"""
if not ready:
return
self._is_initialized = True
self._startup_timer.stop()
if self._startup_cmd:
self.write(self._startup_cmd)
self.initialized.emit()
@SafeProperty(str)
def startup_cmd(self):
"""
Get the startup command for the web console.
"""
return self._startup_cmd
@startup_cmd.setter
def startup_cmd(self, cmd: str):
"""
Set the startup command for the web console.
"""
if not isinstance(cmd, str):
raise ValueError("Startup command must be a string.")
self._startup_cmd = cmd
def write(self, data: str, send_return: bool = True):
"""
@@ -213,10 +261,19 @@ class WebConsole(BECWidget, QWidget):
"document.querySelector('textarea.xterm-helper-textarea').dispatchEvent(new KeyboardEvent('keypress', {charCode: 3}))"
)
def set_readonly(self, readonly: bool):
"""
Set the web console to read-only mode.
"""
if not isinstance(readonly, bool):
raise ValueError("Readonly must be a boolean.")
self.setEnabled(not readonly)
def cleanup(self):
"""
Clean up the registry by removing any instances that are no longer valid.
"""
self._startup_timer.stop()
_web_console_registry.unregister(self)
super().cleanup()

View File

@@ -5,11 +5,13 @@ from typing import TYPE_CHECKING
from bec_lib.callback_handler import EventType
from bec_lib.logger import bec_logger
from bec_lib.messages import ScanHistoryMessage
from bec_qthemes import material_icon
from qtpy import QtCore, QtGui, QtWidgets
from bec_widgets.utils.bec_widget import BECWidget, ConnectionConfig
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
if TYPE_CHECKING:
from bec_lib.client import BECClient
@@ -25,22 +27,38 @@ class BECHistoryManager(QtCore.QObject):
# ScanHistoryMessage.model_dump() (dict)
scan_history_updated = QtCore.Signal(dict)
scan_history_refreshed = QtCore.Signal(list)
def __init__(self, parent, client: BECClient):
super().__init__(parent)
self._load_attempt = 0
self.client = client
self._cb_id = self.client.callbacks.register(
event_type=EventType.SCAN_HISTORY_UPDATE, callback=self._on_scan_history_update
self._cb_id: dict[str, int] = {}
self._cb_id["update_scan_history"] = self.client.callbacks.register(
EventType.SCAN_HISTORY_UPDATE, self._on_scan_history_update
)
self._cb_id["scan_history_loaded"] = self.client.callbacks.register(
EventType.SCAN_HISTORY_LOADED, self._on_scan_history_reloaded
)
def refresh_scan_history(self) -> None:
"""Refresh the scan history from the client."""
all_messages = []
# pylint: disable=protected-access
for scan_id in self.client.history._scan_ids: # pylint: disable=protected-access
history_msg = self.client.history._scan_data.get(scan_id, None)
if history_msg is None:
logger.info(f"Scan history message for scan_id {scan_id} not found.")
continue
self.scan_history_updated.emit(history_msg.model_dump())
all_messages.append(history_msg.model_dump())
self.scan_history_refreshed.emit(all_messages)
def _on_scan_history_reloaded(self, history_msgs: list[ScanHistoryMessage]) -> None:
"""Handle scan history reloaded event from the client."""
if not history_msgs:
logger.warning("Scan history reloaded with no messages.")
return
self.scan_history_refreshed.emit([msg.model_dump() for msg in history_msgs])
def _on_scan_history_update(self, history_msg: ScanHistoryMessage) -> None:
"""Handle scan history updates from the client."""
@@ -48,8 +66,10 @@ class BECHistoryManager(QtCore.QObject):
def cleanup(self) -> None:
"""Clean up the manager by disconnecting callbacks."""
self.client.callbacks.remove(self._cb_id)
for cb_id in self._cb_id.values():
self.client.callbacks.remove(cb_id)
self.scan_history_updated.disconnect()
self.scan_history_refreshed.disconnect()
class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
@@ -80,15 +100,10 @@ class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
theme_update=theme_update,
**kwargs,
)
colors = get_accent_colors()
self.status_colors = {
"closed": colors.success,
"halted": colors.warning,
"aborted": colors.emergency,
}
# self.status_colors = {"closed": "#00e676", "halted": "#ffca28", "aborted": "#ff5252"}
self.status_icons = self._create_status_icons()
self.column_header = ["Scan Nr", "Scan Name", "Status"]
self.scan_history: list[ScanHistoryMessage] = [] # newest at index 0
self.scan_history_ids: set[str] = set() # scan IDs of the scan history
self.max_length = max_length # Maximum number of scan history entries to keep
self.bec_scan_history_manager = BECHistoryManager(parent=self, client=self.client)
self._set_policies()
@@ -97,6 +112,12 @@ class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
header = self.header()
header.setToolTip(f"Last {self.max_length} scans in history.")
self.bec_scan_history_manager.scan_history_updated.connect(self.update_history)
self.bec_scan_history_manager.scan_history_refreshed.connect(self.update_full_history)
self._container = QtWidgets.QStackedLayout()
self._container.setStackingMode(QtWidgets.QStackedLayout.StackAll)
self.setLayout(self._container)
self._add_overlay()
self._start_waiting_display()
self.refresh()
def _set_policies(self):
@@ -117,16 +138,52 @@ class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
for column in range(1, self.columnCount()):
header.setSectionResizeMode(column, QtWidgets.QHeaderView.ResizeMode.Stretch)
def _create_status_icons(self) -> dict[str, QtGui.QIcon]:
"""Create status icons for the scan history."""
colors = get_accent_colors()
return {
"closed": material_icon(
icon_name="fiber_manual_record", filled=True, color=colors.success
),
"halted": material_icon(
icon_name="fiber_manual_record", filled=True, color=colors.warning
),
"aborted": material_icon(
icon_name="fiber_manual_record", filled=True, color=colors.emergency
),
"unknown": material_icon(
icon_name="fiber_manual_record", filled=True, color=QtGui.QColor("#b0bec5")
),
}
def apply_theme(self, theme: str | None = None):
"""Apply the theme to the widget."""
colors = get_accent_colors()
self.status_colors = {
"closed": colors.success,
"halted": colors.warning,
"aborted": colors.emergency,
}
self.status_icons = self._create_status_icons()
self.repaint()
def _add_overlay(self):
self._overlay_widget = QtWidgets.QWidget()
self._overlay_widget.setStyleSheet("background-color: rgba(240, 240, 240, 180);")
self._overlay_widget.setAutoFillBackground(True)
self._overlay_layout = QtWidgets.QVBoxLayout()
self._overlay_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self._overlay_widget.setLayout(self._overlay_layout)
self._spinner = SpinnerWidget(parent=self)
self._spinner.setFixedSize(QtCore.QSize(32, 32))
self._overlay_layout.addWidget(self._spinner)
self._container.addWidget(self._overlay_widget)
def _start_waiting_display(self):
self._overlay_widget.setVisible(True)
self._spinner.start()
QtWidgets.QApplication.processEvents()
def _stop_waiting_display(self):
self._overlay_widget.setVisible(False)
self._spinner.stop()
QtWidgets.QApplication.processEvents()
def _current_item_changed(
self, current: QtWidgets.QTreeWidgetItem, previous: QtWidgets.QTreeWidgetItem
):
@@ -145,9 +202,14 @@ class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
@SafeSlot()
def refresh(self):
"""Refresh the scan history view."""
while len(self.scan_history) > 0:
self.remove_scan(index=0)
self.bec_scan_history_manager.refresh_scan_history()
# pylint: disable=protected-access
if self.client.history._scan_history_loaded_event.is_set():
while len(self.scan_history) > 0:
self.remove_scan(index=0)
self.bec_scan_history_manager.refresh_scan_history()
return
else:
logger.info("Scan history not loaded yet, waiting for it to be loaded.")
@SafeSlot(dict)
def update_history(self, msg_dump: dict):
@@ -156,6 +218,20 @@ class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
self.add_scan(msg)
self.ensure_history_max_length()
@SafeSlot(list)
def update_full_history(self, all_messages: list[dict]):
"""Update the scan history with a full list of scan data."""
messages = []
for msg_dump in all_messages:
msg = ScanHistoryMessage(**msg_dump)
messages.append(msg)
if len(messages) >= self.max_length:
messages.pop(0)
messages.sort(key=lambda m: m.scan_number, reverse=False)
self.add_scans(messages)
self.ensure_history_max_length()
self._stop_waiting_display()
def ensure_history_max_length(self) -> None:
"""
Method to ensure the scan history does not exceed the maximum length.
@@ -172,6 +248,34 @@ class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
"""
Add a scan entry to the tree widget.
Args:
msg (ScanHistoryMessage): The scan history message containing scan details.
"""
self._add_scan_to_scan_history(msg)
tree_item = self._setup_tree_item(msg)
self.insertTopLevelItem(0, tree_item)
def _setup_tree_item(self, msg: ScanHistoryMessage) -> QtWidgets.QTreeWidgetItem:
"""Setup a tree item for the scan history message.
Args:
msg (ScanHistoryMessage): The scan history message containing scan details.
Returns:
QtWidgets.QTreeWidgetItem: The tree item representing the scan history message.
"""
tree_item = QtWidgets.QTreeWidgetItem([str(msg.scan_number), msg.scan_name, ""])
icon = self.status_icons.get(msg.exit_status, self.status_icons["unknown"])
tree_item.setIcon(2, icon)
tree_item.setExpanded(False)
for col in range(tree_item.columnCount()):
tree_item.setToolTip(col, f"Status: {msg.exit_status}")
return tree_item
def _add_scan_to_scan_history(self, msg: ScanHistoryMessage):
"""
Add a scan message to the internal scan history list and update the tree widget.
Args:
msg (ScanHistoryMessage): The scan history message containing scan details.
"""
@@ -180,25 +284,25 @@ class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
f"Old scan history entry fo scan {msg.scan_id} without stored_data_info, skipping."
)
return
if msg in self.scan_history:
if msg.scan_id in self.scan_history_ids:
logger.info(f"Scan {msg.scan_id} already in history, skipping.")
return
self.scan_history.insert(0, msg)
tree_item = QtWidgets.QTreeWidgetItem([str(msg.scan_number), msg.scan_name, ""])
color = QtGui.QColor(self.status_colors.get(msg.exit_status, "#b0bec5"))
pix = QtGui.QPixmap(10, 10)
pix.fill(QtCore.Qt.transparent)
with QtGui.QPainter(pix) as p:
p.setRenderHint(QtGui.QPainter.Antialiasing)
p.setPen(QtCore.Qt.NoPen)
p.setBrush(color)
p.drawEllipse(0, 0, 10, 10)
tree_item.setIcon(2, QtGui.QIcon(pix))
tree_item.setForeground(2, QtGui.QBrush(color))
for col in range(tree_item.columnCount()):
tree_item.setToolTip(col, f"Status: {msg.exit_status}")
self.insertTopLevelItem(0, tree_item)
tree_item.setExpanded(False)
self.scan_history_ids.add(msg.scan_id)
def add_scans(self, messages: list[ScanHistoryMessage]):
"""
Add multiple scan entries to the tree widget.
Args:
messages (list[ScanHistoryMessage]): List of scan history messages containing scan details.
"""
tree_items = []
for msg in messages:
self._add_scan_to_scan_history(msg)
tree_items.append(self._setup_tree_item(msg))
# Insert for insertTopLevelItems needs to reversed to keep order of scan_history list
self.insertTopLevelItems(0, tree_items[::-1])
def remove_scan(self, index: int):
"""
@@ -212,6 +316,7 @@ class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
index = len(self.scan_history) + index
try:
msg = self.scan_history.pop(index)
self.scan_history_ids.remove(msg.scan_id)
self.no_scan_selected.emit()
except IndexError:
logger.warning(f"Invalid index {index} for removing scan entry from history.")

View File

@@ -2,11 +2,13 @@ from __future__ import annotations
import sys
import traceback
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Sequence
import numpy as np
from bec_lib.device import Device, Signal
from bec_lib.endpoints import MessageEndpoints
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtCore import Signal as QSignal
from qtpy.QtWidgets import (
QApplication,
@@ -20,17 +22,10 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.utils.bec_connector import ConnectionConfig
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
from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import (
DeviceInputConfig,
)
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
DeviceSignalInputBaseConfig,
)
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit,
)
@@ -48,8 +43,9 @@ class ChoiceDialog(QDialog):
def __init__(
self,
parent: QWidget | None = None,
config: ConnectionConfig | None = None,
client: BECClient | None = None,
device: str | None = None,
signal: str | None = None,
show_hinted: bool = True,
show_normal: bool = False,
show_config: bool = False,
@@ -63,18 +59,8 @@ class ChoiceDialog(QDialog):
layout = QHBoxLayout()
config_dict = config.model_dump() if config is not None else {}
self._device_config = DeviceInputConfig.model_validate(config_dict)
self._signal_config = DeviceSignalInputBaseConfig.model_validate(config_dict)
self._device_field = DeviceLineEdit(
config=self._device_config, parent=parent, client=client
)
self._signal_field = SignalComboBox(
config=self._signal_config,
device=self._signal_config.device,
parent=parent,
client=client,
)
self._device_field = DeviceLineEdit(parent=parent, client=client)
self._signal_field = SignalComboBox(parent=parent, client=client)
layout.addWidget(self._device_field)
layout.addWidget(self._signal_field)
@@ -89,7 +75,10 @@ class ChoiceDialog(QDialog):
self.setLayout(layout)
self._device_field.textChanged.connect(self._update_device)
self._device_field.setText(config.device if config is not None else "")
if device:
self._device_field.set_device(device)
if signal and signal in set(s[0] for s in self._signal_field.signals):
self._signal_field.set_signal(signal)
def _display_error(self):
try:
@@ -123,11 +112,19 @@ class ChoiceDialog(QDialog):
self.accepted_output.emit(
self._device_field.text(), self._signal_field.selected_signal_comp_name
)
self.cleanup()
return super().accept()
def reject(self):
self.cleanup()
return super().reject()
def cleanup(self):
self._device_field.close()
self._signal_field.close()
class SignalLabel(BECWidget, QWidget):
ICON_NAME = "scoreboard"
RPC = True
PLUGIN = True
@@ -151,6 +148,8 @@ class SignalLabel(BECWidget, QWidget):
"show_config_signals.setter",
"display_array_data",
"display_array_data.setter",
"max_list_display_len",
"max_list_display_len.setter",
]
def __init__(
@@ -178,7 +177,6 @@ class SignalLabel(BECWidget, QWidget):
custom_label (str, optional): Custom label for the widget. Defaults to "".
custom_units (str, optional): Custom units for the widget. Defaults to "".
"""
self._config = DeviceSignalInputBaseConfig(default=signal, device=device)
super().__init__(parent=parent, client=client, **kwargs)
self._device = device
@@ -189,6 +187,7 @@ class SignalLabel(BECWidget, QWidget):
self._show_default_units: bool = show_default_units
self._decimal_places = 3
self._dtype = None
self._max_list_display_len = 5
self._show_hinted_signals: bool = True
self._show_normal_signals: bool = True
@@ -227,9 +226,10 @@ class SignalLabel(BECWidget, QWidget):
def _create_dialog(self):
return ChoiceDialog(
config=self._config,
parent=self,
client=self.client,
device=self.device,
signal=self._signal_key,
show_config=self.show_config_signals,
show_normal=self.show_normal_signals,
show_hinted=self.show_hinted_signals,
@@ -280,7 +280,7 @@ class SignalLabel(BECWidget, QWidget):
return
self._value = value
self._units = self._signal_info.get("egu", "")
self._dtype = self._signal_info.get("dtype", "float")
self._dtype = self._signal_info.get("dtype")
@SafeSlot(dict, dict)
def on_device_readback(self, msg: dict, metadata: dict) -> None:
@@ -305,11 +305,13 @@ class SignalLabel(BECWidget, QWidget):
except KeyError:
return "", {}
if signal_info["kind_str"] == Kind.hinted.name:
return signal_info["obj_name"], signal_info
return signal_info["obj_name"], signal_info.get("describe", {})
else:
return f"{self._device}_{self._signal}", signal_info
return f"{self._device}_{self._signal}", signal_info.get("describe", {})
elif isinstance(self._device_obj, Signal):
return self._device, self._device_obj._info["describe_configuration"]
info = self._device_obj._info["describe_configuration"][self._device]
info["egu"] = self._device_obj._info["describe_configuration"].get("egu")
return (self._device, info)
return "", {}
@SafeProperty(str)
@@ -322,7 +324,6 @@ class SignalLabel(BECWidget, QWidget):
self.disconnect_device()
self._device = value
self._device_obj = self.dev.get(self._device)
self._config.device = value
self.connect_device()
self._update_label()
@@ -335,7 +336,6 @@ class SignalLabel(BECWidget, QWidget):
def signal(self, value: str) -> None:
self.disconnect_device()
self._signal = value
self._config.default = value
self.connect_device()
self._update_label()
@@ -369,6 +369,16 @@ class SignalLabel(BECWidget, QWidget):
self._custom_label = value
self._update_label()
@SafeProperty(str)
def max_list_display_len(self) -> int:
"""For small lists, the max length to display"""
return self._max_list_display_len
@max_list_display_len.setter
def max_list_display_len(self, value: int) -> None:
self._max_list_display_len = value
self.set_display_value(self._value)
@SafeProperty(str)
def custom_units(self) -> str:
"""Use a custom unit string"""
@@ -429,6 +439,11 @@ class SignalLabel(BECWidget, QWidget):
def _format_value(self, value: Any):
if self._dtype == "array" and not self.display_array_data:
return "ARRAY DATA"
if not isinstance(value, str) and isinstance(value, (Sequence, np.ndarray)):
if len(value) < self._max_list_display_len:
return str(value)
else:
return "ARRAY DATA"
if self._decimal_places == 0:
return value
try:
@@ -468,7 +483,6 @@ class SignalLabel(BECWidget, QWidget):
if __name__ == "__main__":
app = QApplication(sys.argv)
w = QWidget()
w.setLayout(QVBoxLayout())

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "2.32.0"
version = "2.35.0"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [
@@ -13,17 +13,17 @@ classifiers = [
"Topic :: Scientific/Engineering",
]
dependencies = [
"bec_ipython_client>=3.42.4, <=4.0", # needed for jupyter console
"bec_lib>=3.44, <=4.0",
"bec_ipython_client~=3.52", # needed for jupyter console
"bec_lib~=3.52",
"bec_qthemes~=0.7, >=0.7",
"black~=25.0", # needed for bw-generate-cli
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
"black~=25.0", # needed for bw-generate-cli
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
"pydantic~=2.0",
"pyqtgraph~=0.13",
"PySide6~=6.8.2",
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"PySide6==6.9.0",
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"qtpy~=2.4",
"qtmonaco>=0.2.3",
"qtmonaco~=0.5",
]
@@ -39,6 +39,8 @@ dev = [
"pytest-xvfb~=3.0",
"pytest~=8.0",
"pytest-cov~=6.1.1",
"watchdog~=6.0",
"pre_commit~=4.2",
]
[project.urls]

View File

@@ -67,7 +67,7 @@ def test_client_utils_passes_client_config_to_server(bec_dispatcher):
mixin._client = bec_dispatcher.client
mixin._gui_id = "gui_id"
mixin._gui_is_alive = mock.MagicMock()
mixin._gui_is_alive.side_effect = [True]
mixin._gui_is_alive.side_effect = [False, False, True]
try:
yield mixin

View File

@@ -0,0 +1,255 @@
import os
import subprocess
import time
from pathlib import Path
from time import sleep
from types import SimpleNamespace
from unittest.mock import MagicMock, call, patch
import copier
import pytest
from bec_lib.utils.plugin_manager import main
from bec_lib.utils.plugin_manager._util import _goto_dir
from typer.testing import CliRunner
from bec_widgets.utils.bec_plugin_manager.create.widget import _commit_added_widget, _widget_exists
PLUGIN_REPO = "https://github.com/bec-project/plugin_copier_template.git"
REPLACEMENT_UI_CONTENTS = """<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>testWidget6</class>
<widget class="QWidget" name="testWidget6">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>539</width>
<height>287</height>
</rect>
</property>
<widget class="Waveform" name="waveform">
<property name="geometry">
<rect>
<x>30</x>
<y>0</y>
<width>361</width>
<height>125</height>
</rect>
</property>
</widget>
</widget>
<customwidgets>
<customwidget>
<class>Waveform</class>
<extends>QWidget</extends>
<header>waveform</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>
"""
@pytest.fixture
def runner():
return CliRunner()
def test_app_has_widget_commands(runner: CliRunner):
result = runner.invoke(main._app, ["create", "--help"])
assert "widget" in result.output
def test_create_widget_takes_name(runner: CliRunner):
result = runner.invoke(main._app, ["create", "widget"])
assert "Missing argument 'NAME'." in result.output
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.logger")
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.make_commit")
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.git_stage_files")
def test_make_commit(stage: MagicMock, commit: MagicMock, logger: MagicMock):
repo = Path("test_path")
_commit_added_widget(repo, "test")
assert stage.call_count == 2
stage.assert_has_calls(
[
call(repo, [".copier-answers.yml"]),
call(repo / repo.name / "bec_widgets" / "widgets" / "test", []),
]
)
commit.assert_called_with(repo, "plugin-manager added new widget: test")
logger.info.assert_called_with("Committing new widget test")
def test_widget_exists_function():
assert not _widget_exists([], "test_widget")
assert _widget_exists([{"name": "test_widget", "use_ui": True}], "test_widget")
def test_editor_cb(runner):
result = runner.invoke(main._app, ["create", "widget", "test", "--no-use-ui", "--open-editor"])
assert result.exit_code == 2
assert "Invalid value" in result.output
assert "Can only open" in result.output
class TestAddWidgetVariants:
@pytest.fixture(scope="class", autouse=True)
def setup_env(self, tmp_path_factory: pytest.TempPathFactory):
TestAddWidgetVariants._tmp_plugin_dir = tmp_path_factory.mktemp("test_plugin")
@pytest.fixture(scope="function", autouse=True)
def cleanup_repo(self):
yield
subprocess.run(["git", "reset", "--hard"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
@pytest.fixture(scope="class")
def git_repo(self):
project = TestAddWidgetVariants._tmp_plugin_dir / "test_plugin"
with _goto_dir(TestAddWidgetVariants._tmp_plugin_dir):
subprocess.run(["git", "clone", PLUGIN_REPO])
os.makedirs(project)
with _goto_dir(project):
subprocess.run(["git", "init", "-b", "main"])
subprocess.run(["git", "config", "user.email", "test"])
subprocess.run(["git", "config", "user.name", "test"])
copier.run_copy(
str(TestAddWidgetVariants._tmp_plugin_dir / "plugin_copier_template"),
str(project),
defaults=True,
data={
"project_name": "test_plugin",
"widget_plugins_input": [{"name": "test_widget", "use_ui": True}],
},
unsafe=True,
)
yield project
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.plugin_repo_path")
def test_add_widget_with_ui(self, plugin_repo_path, runner: CliRunner, git_repo: Path):
plugin_repo_path.return_value = str(git_repo)
result = runner.invoke(
main._app, ["create", "widget", "test_widget_2", "--use-ui", "--no-open-editor"]
)
assert result.exit_code == 0, result.output
widget_dir = git_repo / "test_plugin" / "bec_widgets" / "widgets" / "test_widget_2"
assert os.path.isdir(widget_dir)
assert os.path.isfile(widget_dir / "test_widget_2.py")
assert os.path.isfile(widget_dir / "test_widget_2.ui")
assert os.path.isfile(widget_dir / "test_widget_2_ui.py")
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.plugin_repo_path")
def test_add_widget_without_ui(self, plugin_repo_path, runner: CliRunner, git_repo: Path):
plugin_repo_path.return_value = str(git_repo)
result = runner.invoke(
main._app, ["create", "widget", "test_widget_3", "--no-use-ui", "--no-open-editor"]
)
assert result.exit_code == 0, result.output
widget_dir = git_repo / "test_plugin" / "bec_widgets" / "widgets" / "test_widget_3"
assert os.path.isdir(widget_dir)
assert os.path.isfile(widget_dir / "test_widget_3.py")
assert not os.path.isfile(widget_dir / "test_widget_3.ui")
assert not os.path.isfile(widget_dir / "test_widget_3_ui.py")
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.logger")
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.plugin_repo_path")
def test_no_add_widget_dupe_name(
self, plugin_repo_path, logger, runner: CliRunner, git_repo: Path
):
plugin_repo_path.return_value = str(git_repo)
result = runner.invoke(
main._app, ["create", "widget", "test_widget", "--no-use-ui", "--no-open-editor"]
)
assert result.exit_code == -1, result.output
assert "already exists!" in logger.error.mock_calls[0].args[0]
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.logger")
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.plugin_repo_path")
def test_no_add_widget_bad_name(
self, plugin_repo_path, logger, runner: CliRunner, git_repo: Path
):
plugin_repo_path.return_value = str(git_repo)
result = runner.invoke(
main._app, ["create", "widget", "12345", "--no-use-ui", "--no-open-editor"]
)
assert result.exit_code == -1, result.output
assert "not a valid name for a widget" in logger.error.mock_calls[0].args[0]
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.copier")
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.logger")
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.plugin_repo_path")
def test_copier_error_logged(
self, plugin_repo_path, logger, copier, runner: CliRunner, git_repo: Path
):
class CopierFailure(Exception): ...
copier.run_update.side_effect = CopierFailure
plugin_repo_path.return_value = str(git_repo)
result = runner.invoke(
main._app, ["create", "widget", "test_widget_4", "--no-use-ui", "--no-open-editor"]
)
assert result.exit_code == -1, result.output
assert "CopierFailure" in logger.error.mock_calls[0].args[0]
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.open_and_watch_ui_editor")
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.plugin_repo_path")
def test_editor_opened_on_success(
self, plugin_repo_path, open_editor, runner: CliRunner, git_repo: Path
):
plugin_repo_path.return_value = str(git_repo)
runner.invoke(main._app, ["create", "widget", "TeSt_wiDgeT_5", "--use-ui", "--open-editor"])
open_editor.assert_called_with("test_widget_5")
@patch("bec_widgets.utils.bec_plugin_manager.edit_ui.logger")
@patch("bec_widgets.utils.bec_plugin_manager.edit_ui.open_designer")
@patch("bec_widgets.utils.bec_plugin_manager.edit_ui.plugin_repo_path")
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.plugin_repo_path")
@patch("bec_widgets.utils.bec_plugin_manager.edit_ui.plugin_package_name")
def test_widget_editor_watcher(
self,
plugin_package_name,
plugin_repo_path,
plugin_repo_path_2,
open_designer,
logger: MagicMock,
runner: CliRunner,
git_repo: Path,
):
plugin_repo_path.return_value = str(git_repo)
plugin_repo_path_2.return_value = str(git_repo)
plugin_package_name.return_value = git_repo.name
widget_dir = git_repo / "test_plugin" / "bec_widgets" / "widgets" / "test_widget_6"
widget_ui_file = widget_dir / "test_widget_6.ui"
compiled_widget_ui_file = widget_dir / "test_widget_6_ui.py"
test_collector = SimpleNamespace()
def test_function(args: list[str]):
test_collector.ui_file = args[0]
with open(compiled_widget_ui_file) as f:
test_collector.initial_compiled_ui_contents = f.read()
with open(args[0], "w") as f:
f.write(REPLACEMENT_UI_CONTENTS)
start = time.monotonic()
while call("done!") not in logger.success.call_args_list:
time.sleep(0.05)
if time.monotonic() - start > 5:
raise TimeoutError("Waiting for recompilation timed out.")
with open(compiled_widget_ui_file) as f:
test_collector.final_compiled_ui_contents = f.read()
open_designer.side_effect = test_function
result = runner.invoke(
main._app, ["create", "widget", "test_widget_6", "--use-ui", "--open-editor"]
)
assert result.exit_code == 0, result.output
assert test_collector.ui_file == str(widget_ui_file)
assert (
test_collector.initial_compiled_ui_contents != test_collector.final_compiled_ui_contents
)
assert "" in test_collector.final_compiled_ui_contents

View File

@@ -0,0 +1,586 @@
from unittest.mock import Mock
import pytest
from qtpy import QtWidgets
from qtpy.QtCore import QLocale, QPoint, QPointF, QRect, QRectF, QSize, QSizeF, Qt
from qtpy.QtGui import QColor, QCursor, QFont, QIcon, QPalette
from qtpy.QtWidgets import QLabel, QPushButton, QSizePolicy, QWidget
from bec_widgets.utils.property_editor import PropertyEditor
class TestWidget(QWidget):
"""Test widget with various property types for testing the property editor."""
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("TestWidget")
# Set up various properties that will appear in the property editor
self.setMinimumSize(100, 50)
self.setMaximumSize(500, 300)
self.setStyleSheet("background-color: red;")
self.setToolTip("Test tooltip")
self.setEnabled(True)
self.setVisible(True)
class BECTestWidget(QWidget):
"""Test widget that simulates a BEC widget."""
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("BECTestWidget")
# This widget's module will be set to simulate a bec_widgets module
self.__module__ = "bec_widgets.test.widget"
@pytest.fixture
def test_widget(qtbot):
"""Fixture providing a test widget with various properties."""
widget = TestWidget()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
return widget
@pytest.fixture
def bec_test_widget(qtbot):
"""Fixture providing a BEC test widget."""
widget = BECTestWidget()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
return widget
@pytest.fixture
def property_editor(qtbot, test_widget):
"""Fixture providing a property editor with a test widget."""
editor = PropertyEditor(test_widget, show_only_bec=False)
qtbot.addWidget(editor)
qtbot.waitExposed(editor)
return editor
@pytest.fixture
def bec_property_editor(qtbot, bec_test_widget):
"""Fixture providing a property editor with BEC-only mode."""
editor = PropertyEditor(bec_test_widget, show_only_bec=True)
qtbot.addWidget(editor)
qtbot.waitExposed(editor)
return editor
# ------------------------------------------------------------------------
# Basic functionality tests
# ------------------------------------------------------------------------
def test_initialization(property_editor, test_widget):
"""Test that the property editor initializes correctly."""
assert property_editor._target == test_widget
assert property_editor._bec_only is False
assert property_editor.tree.columnCount() == 2
assert property_editor.tree.headerItem().text(0) == "Property"
assert property_editor.tree.headerItem().text(1) == "Value"
def test_bec_only_mode(bec_property_editor):
"""Test BEC-only mode filtering."""
assert bec_property_editor._bec_only is True
# Should have items since bec_test_widget simulates a BEC widget
assert bec_property_editor.tree.topLevelItemCount() >= 0
def test_class_chain(property_editor, test_widget):
"""Test that _class_chain returns correct metaobject hierarchy."""
chain = property_editor._class_chain()
assert len(chain) > 0
# First item should be the most derived class
assert chain[0].className() in ["TestWidget", "QWidget"]
def test_set_show_only_bec_toggle(property_editor):
"""Test toggling BEC-only mode rebuilds the tree."""
initial_count = property_editor.tree.topLevelItemCount()
# Toggle to BEC-only mode
property_editor.set_show_only_bec(True)
assert property_editor._bec_only is True
# Toggle back
property_editor.set_show_only_bec(False)
assert property_editor._bec_only is False
# ------------------------------------------------------------------------
# Editor creation tests
# ------------------------------------------------------------------------
def test_make_sizepolicy_editor(property_editor):
"""Test size policy editor creation and functionality."""
size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
size_policy.setHorizontalStretch(1)
size_policy.setVerticalStretch(2)
editor = property_editor._make_sizepolicy_editor("sizePolicy", size_policy)
assert editor is not None
# Should return None for non-QSizePolicy input
editor_none = property_editor._make_sizepolicy_editor("test", "not_a_sizepolicy")
assert editor_none is None
def test_make_locale_editor(property_editor):
"""Test locale editor creation."""
locale = QLocale(QLocale.English, QLocale.UnitedStates)
editor = property_editor._make_locale_editor("locale", locale)
assert editor is not None
# Should return None for non-QLocale input
editor_none = property_editor._make_locale_editor("test", "not_a_locale")
assert editor_none is None
def test_make_icon_editor(property_editor):
"""Test icon editor creation."""
icon = QIcon()
editor = property_editor._make_icon_editor("icon", icon)
assert editor is not None
assert isinstance(editor, QPushButton)
assert "Choose" in editor.text()
def test_make_font_editor(property_editor):
"""Test font editor creation."""
font = QFont("Arial", 12)
editor = property_editor._make_font_editor("font", font)
assert editor is not None
assert isinstance(editor, QPushButton)
assert "Arial" in editor.text()
assert "12" in editor.text()
# Test with non-font value
editor_no_font = property_editor._make_font_editor("font", None)
assert "Select font" in editor_no_font.text()
def test_make_color_editor(property_editor):
"""Test color editor creation."""
color = QColor(255, 0, 0) # Red color
apply_called = []
def apply_callback(col):
apply_called.append(col)
editor = property_editor._make_color_editor(color, apply_callback)
assert editor is not None
assert isinstance(editor, QPushButton)
assert color.name() in editor.text()
def test_make_cursor_editor(property_editor):
"""Test cursor editor creation."""
cursor = QCursor(Qt.CrossCursor)
editor = property_editor._make_cursor_editor("cursor", cursor)
assert editor is not None
assert isinstance(editor, QtWidgets.QComboBox)
def test_spin_pair_int(property_editor):
"""Test _spin_pair with integer spinboxes."""
wrap, box1, box2 = property_editor._spin_pair(ints=True)
assert wrap is not None
assert isinstance(box1, QtWidgets.QSpinBox)
assert isinstance(box2, QtWidgets.QSpinBox)
assert box1.minimum() == -10_000_000
assert box1.maximum() == 10_000_000
def test_spin_pair_float(property_editor):
"""Test _spin_pair with double spinboxes."""
wrap, box1, box2 = property_editor._spin_pair(ints=False)
assert wrap is not None
assert isinstance(box1, QtWidgets.QDoubleSpinBox)
assert isinstance(box2, QtWidgets.QDoubleSpinBox)
assert box1.decimals() == 6
def test_spin_quad_int(property_editor):
"""Test _spin_quad with integer spinboxes."""
wrap, boxes = property_editor._spin_quad(ints=True)
assert wrap is not None
assert len(boxes) == 4
assert all(isinstance(box, QtWidgets.QSpinBox) for box in boxes)
def test_spin_quad_float(property_editor):
"""Test _spin_quad with double spinboxes."""
wrap, boxes = property_editor._spin_quad(ints=False)
assert wrap is not None
assert len(boxes) == 4
assert all(isinstance(box, QtWidgets.QDoubleSpinBox) for box in boxes)
# ------------------------------------------------------------------------
# Property type editor tests
# ------------------------------------------------------------------------
def test_make_editor_qsize(property_editor):
"""Test editor creation for QSize properties."""
size = QSize(100, 200)
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
editor = property_editor._make_editor("size", size, mock_prop)
assert editor is not None
def test_make_editor_qsizef(property_editor):
"""Test editor creation for QSizeF properties."""
sizef = QSizeF(100.5, 200.7)
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
editor = property_editor._make_editor("sizef", sizef, mock_prop)
assert editor is not None
def test_make_editor_qpoint(property_editor):
"""Test editor creation for QPoint properties."""
point = QPoint(10, 20)
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
editor = property_editor._make_editor("point", point, mock_prop)
assert editor is not None
def test_make_editor_qpointf(property_editor):
"""Test editor creation for QPointF properties."""
pointf = QPointF(10.5, 20.7)
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
editor = property_editor._make_editor("pointf", pointf, mock_prop)
assert editor is not None
def test_make_editor_qrect(property_editor):
"""Test editor creation for QRect properties."""
rect = QRect(10, 20, 100, 200)
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
editor = property_editor._make_editor("rect", rect, mock_prop)
assert editor is not None
def test_make_editor_qrectf(property_editor):
"""Test editor creation for QRectF properties."""
rectf = QRectF(10.5, 20.7, 100.5, 200.7)
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
editor = property_editor._make_editor("rectf", rectf, mock_prop)
assert editor is not None
def test_make_editor_bool(property_editor):
"""Test editor creation for boolean properties."""
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
editor = property_editor._make_editor("enabled", True, mock_prop)
assert editor is not None
assert isinstance(editor, QtWidgets.QCheckBox)
assert editor.isChecked() is True
def test_make_editor_int(property_editor):
"""Test editor creation for integer properties."""
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
editor = property_editor._make_editor("value", 42, mock_prop)
assert editor is not None
assert isinstance(editor, QtWidgets.QSpinBox)
assert editor.value() == 42
def test_make_editor_float(property_editor):
"""Test editor creation for float properties."""
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
editor = property_editor._make_editor("value", 3.14, mock_prop)
assert editor is not None
assert isinstance(editor, QtWidgets.QDoubleSpinBox)
assert editor.value() == 3.14
def test_make_editor_string(property_editor):
"""Test editor creation for string properties."""
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
editor = property_editor._make_editor("text", "Hello World", mock_prop)
assert editor is not None
assert isinstance(editor, QtWidgets.QLineEdit)
assert editor.text() == "Hello World"
def test_make_editor_qcolor(property_editor):
"""Test editor creation for QColor properties."""
color = QColor(255, 0, 0)
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
editor = property_editor._make_editor("color", color, mock_prop)
assert editor is not None
assert isinstance(editor, QPushButton)
def test_make_editor_qfont(property_editor):
"""Test editor creation for QFont properties."""
font = QFont("Arial", 12)
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
editor = property_editor._make_editor("font", font, mock_prop)
assert editor is not None
assert isinstance(editor, QPushButton)
def test_make_editor_unsupported_type(property_editor):
"""Test editor creation for unsupported property types."""
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
# Should return None for unsupported types
editor = property_editor._make_editor("unsupported", object(), mock_prop)
assert editor is None
# ------------------------------------------------------------------------
# Enum editor tests
# ------------------------------------------------------------------------
def test_make_enum_editor_non_flag(property_editor):
"""Test enum editor creation for non-flag enums."""
mock_prop = Mock()
mock_prop.isEnumType.return_value = True
mock_enum = Mock()
mock_enum.isFlag.return_value = False
mock_enum.keyCount.return_value = 3
mock_enum.key.side_effect = [b"Value1", b"Value2", b"Value3"]
mock_enum.value.side_effect = [0, 1, 2]
mock_prop.enumerator.return_value = mock_enum
editor = property_editor._make_enum_editor("enum_prop", 1, mock_prop)
assert editor is not None
assert isinstance(editor, QtWidgets.QComboBox)
# ------------------------------------------------------------------------
# Palette editor tests
# ------------------------------------------------------------------------
def test_make_palette_editor(property_editor):
"""Test palette editor creation."""
palette = QPalette()
palette.setColor(QPalette.Window, QColor(255, 255, 255))
editor = property_editor._make_palette_editor("palette", palette)
assert editor is not None
# Should return None for non-QPalette input
editor_none = property_editor._make_palette_editor("test", "not_a_palette")
assert editor_none is None
def test_apply_palette_color(property_editor, test_widget):
"""Test _apply_palette_color method."""
palette = test_widget.palette()
original_color = palette.color(QPalette.Active, QPalette.Window)
new_color = QColor(255, 0, 0)
property_editor._apply_palette_color(
"palette", palette, QPalette.Active, QPalette.Window, new_color
)
# Verify the property was set (this would normally update the widget)
assert palette.color(QPalette.Active, QPalette.Window) == new_color
# ------------------------------------------------------------------------
# Enum text processing tests
# ------------------------------------------------------------------------
def test_enum_text_non_flag(property_editor):
"""Test _enum_text for non-flag enums."""
mock_enum = Mock()
mock_enum.isFlag.return_value = False
mock_enum.valueToKey.return_value = b"TestValue"
result = property_editor._enum_text(mock_enum, 1)
assert result == "TestValue"
def test_enum_text_flag(property_editor):
"""Test _enum_text for flag enums."""
mock_enum = Mock()
mock_enum.isFlag.return_value = True
mock_enum.keyCount.return_value = 2
mock_enum.key.side_effect = [b"Flag1", b"Flag2"]
mock_enum.value.side_effect = [1, 2]
result = property_editor._enum_text(mock_enum, 3) # 1 | 2 = 3
assert "Flag1" in result and "Flag2" in result
def test_enum_value_to_int(property_editor):
"""Test _enum_value_to_int conversion."""
# Test with integer
assert property_editor._enum_value_to_int(Mock(), 42) == 42
# Test with object having value attribute
mock_obj = Mock()
mock_obj.value = 24
assert property_editor._enum_value_to_int(Mock(), mock_obj) == 24
# Test with mock enum for key lookup
mock_enum = Mock()
mock_enum.keyToValue.return_value = 10
mock_obj_with_name = Mock()
mock_obj_with_name.name = "TestKey"
assert property_editor._enum_value_to_int(mock_enum, mock_obj_with_name) == 10
# ------------------------------------------------------------------------
# Tree building and interaction tests
# ------------------------------------------------------------------------
def test_add_property_row(property_editor):
"""Test _add_property_row method."""
parent_item = QtWidgets.QTreeWidgetItem(["TestGroup"])
mock_prop = Mock()
mock_prop.isEnumType.return_value = False
property_editor._add_property_row(parent_item, "testProp", "testValue", mock_prop)
assert parent_item.childCount() == 1
child = parent_item.child(0)
assert child.text(0) == "testProp"
def test_set_equal_columns(property_editor):
"""Test _set_equal_columns method."""
# Set a specific width to test column sizing
property_editor.resize(400, 300)
property_editor._set_equal_columns()
# Verify columns are set up correctly
header = property_editor.tree.header()
assert header.sectionResizeMode(0) == QtWidgets.QHeaderView.Interactive
assert header.sectionResizeMode(1) == QtWidgets.QHeaderView.Interactive
def test_build_rebuilds_tree(property_editor):
"""Test that _build method clears and rebuilds the tree."""
initial_count = property_editor.tree.topLevelItemCount()
# Add a dummy item to ensure clearing works
dummy_item = QtWidgets.QTreeWidgetItem(["Dummy"])
property_editor.tree.addTopLevelItem(dummy_item)
# Rebuild
property_editor._build()
# The dummy item should be gone, tree should be rebuilt
assert property_editor.tree.topLevelItemCount() >= 0
# ------------------------------------------------------------------------
# Integration tests with Qt objects
# ------------------------------------------------------------------------
def test_property_change_integration(qtbot, property_editor, test_widget):
"""Test that property changes through editors update the target widget."""
# This test would require more complex setup to actually trigger editor changes
# For now, just verify the basic structure is there
assert property_editor._target == test_widget
# Verify that the tree has been populated with some properties
assert property_editor.tree.topLevelItemCount() >= 0
def test_widget_with_custom_properties(qtbot):
"""Test property editor with a widget that has custom properties."""
widget = QLabel("Test Label")
widget.setAlignment(Qt.AlignCenter)
widget.setWordWrap(True)
qtbot.addWidget(widget)
editor = PropertyEditor(widget, show_only_bec=False)
qtbot.addWidget(editor)
qtbot.waitExposed(editor)
# Should have populated the tree with QLabel properties
assert editor.tree.topLevelItemCount() > 0
# ------------------------------------------------------------------------
# Error handling tests
# ------------------------------------------------------------------------
def test_robust_enum_handling(property_editor):
"""Test that enum handling is robust against various edge cases."""
# Test with invalid enum values
mock_enum = Mock()
mock_enum.isFlag.return_value = False
mock_enum.valueToKey.return_value = None
result = property_editor._enum_text(mock_enum, 999)
assert result == "999" # Should fall back to string representation
# ------------------------------------------------------------------------
# Performance and memory tests
# ------------------------------------------------------------------------
def test_large_property_tree_performance(qtbot):
"""Test that the property editor handles widgets with many properties reasonably."""
# Create a widget with a deep inheritance hierarchy
widget = QtWidgets.QTextEdit()
widget.setPlainText("Test text with many properties")
qtbot.addWidget(widget)
editor = PropertyEditor(widget, show_only_bec=False)
qtbot.addWidget(editor)
# Should complete without hanging
qtbot.waitExposed(editor)
assert editor.tree.topLevelItemCount() > 0
def test_memory_cleanup_on_rebuild(property_editor):
"""Test that rebuilding the tree properly cleans up widgets."""
initial_count = property_editor.tree.topLevelItemCount()
# Trigger multiple rebuilds
for _ in range(3):
property_editor._build()
# Should not accumulate items
final_count = property_editor.tree.topLevelItemCount()
assert final_count >= 0 # Basic sanity check

View File

@@ -287,6 +287,23 @@ def test_scan_history_view_refresh(qtbot, scan_history_view, scan_history_msg, s
assert scan_history_view.topLevelItemCount() == 0
def test_scan_history_update_full_history(
qtbot, scan_history_view, scan_history_msg, scan_history_msg_2
):
"""Test the update_full_history method of ScanHistoryView."""
# Wait spinner should be visible
scan_history_view.update_full_history(
[scan_history_msg.model_dump(), scan_history_msg_2.model_dump()]
)
assert len(scan_history_view.scan_history) == 2
assert scan_history_view.topLevelItemCount() == 2
assert scan_history_view.scan_history[0] == scan_history_msg_2 # new first item
assert scan_history_view.scan_history[1] == scan_history_msg # old second item
# Wait spinner should be hidden
assert scan_history_view._overlay_widget.isVisible() is False
assert scan_history_view._spinner.isVisible() is False
def test_scan_history_browser(qtbot, scan_history_browser, scan_history_msg, scan_history_msg_2):
"""Test the initialization of ScanHistoryBrowser."""
assert isinstance(scan_history_browser.scan_history_view, ScanHistoryView)
@@ -298,14 +315,14 @@ def test_scan_history_browser(qtbot, scan_history_browser, scan_history_msg, sca
scan_history_browser.scan_history_view.update_history(scan_history_msg_2.model_dump())
assert len(scan_history_browser.scan_history_view.scan_history) == 2
assert scan_history_browser.scan_history_view.topLevelItemCount() == 2
# Click on first scan item history to select it
qtbot.mouseClick(
scan_history_browser.scan_history_view.viewport(),
QtCore.Qt.LeftButton,
pos=scan_history_browser.scan_history_view.visualItemRect(
scan_history_browser.scan_history_view.topLevelItem(0)
).center(),
)
# TODO #771 ; Multiple clicks to the QTreeView item fail, but only in the CI, not locally.
# Simulate a mouse click without qtbot.mouseClick as this is unstable and currently fails in CI
item = scan_history_browser.scan_history_view.topLevelItem(0)
scan_history_browser.scan_history_view.setCurrentItem(item)
scan_history_browser.scan_history_view.itemClicked.emit(item, 0)
assert scan_history_browser.scan_history_view.currentIndex().row() == 0
# Both metadata and device viewers should be updated with the first scan
@@ -320,29 +337,6 @@ def test_scan_history_browser(qtbot, scan_history_browser, scan_history_msg, sca
timeout=2000,
)
# TODO #771 ; Multiple clicks to the QTreeView item fail, but only in the CI, not locally.
# Click on second scan item history to select it
# qtbot.mouseClick(
# scan_history_browser.scan_history_view.viewport(),
# QtCore.Qt.LeftButton,
# pos=scan_history_browser.scan_history_view.visualItemRect(
# scan_history_browser.scan_history_view.topLevelItem(1)
# ).center(),
# )
# assert scan_history_browser.scan_history_view.currentIndex().row() == 1
# # Both metadata and device viewers should be updated with the first scan
# qtbot.waitUntil(
# lambda: scan_history_browser.scan_history_metadata_viewer.scan_history_msg
# == scan_history_msg,
# timeout=2000,
# )
# qtbot.waitUntil(
# lambda: scan_history_browser.scan_history_device_viewer.scan_history_msg
# == scan_history_msg,
# timeout=2000,
# )
callback_args = []
def plotting_callback(device_name, signal_name, msg):

View File

@@ -215,9 +215,7 @@ def test_set_existing_device_and_signal(signal_label: SignalLabel, qtbot):
signal_label.device = "samx"
signal_label.signal = "readback"
assert signal_label._device == "samx"
assert signal_label._config.device == "samx"
assert signal_label._signal == "readback"
assert signal_label._config.default == "readback"
def test_set_nonexisting_device_and_signal(signal_label: SignalLabel, qtbot):
@@ -225,12 +223,10 @@ def test_set_nonexisting_device_and_signal(signal_label: SignalLabel, qtbot):
signal_label.device = "samq"
signal_label.signal = "readfront"
assert signal_label._device == "samq"
assert signal_label._config.device == "samq"
signal_label._manual_read()
signal_label.set_display_value(signal_label._value)
assert signal_label._display.text() == "__"
assert signal_label._signal == "readfront"
assert signal_label._config.default == "readfront"
signal_label._manual_read()
signal_label.set_display_value(signal_label._value)
assert signal_label._display.text() == "__"
@@ -256,3 +252,12 @@ def test_handle_readback(signal_label: SignalLabel, qtbot):
)
assert signal_label._display.text() == "0.993 μm"
assert signal_label._display.toolTip() == ""
def test_handle_lists(signal_label: SignalLabel, qtbot):
signal_label.custom_units = ""
signal_label.set_display_value([1, 2, 3, 4])
assert signal_label._display.text() == "[1, 2, 3, 4]"
signal_label.max_list_display_len = 2
signal_label.set_display_value([1, 2, 3, 4])
assert signal_label._display.text() == "ARRAY DATA"

View File

@@ -88,3 +88,60 @@ def test_web_console_registry_wait_for_server_port_timeout():
with mock.patch.object(_web_console_registry, "_server_process") as mock_subprocess:
with pytest.raises(TimeoutError):
_web_console_registry._wait_for_server_port(timeout=0.1)
def test_web_console_startup_command_execution(console_widget, qtbot):
"""Test that the startup command is triggered after successful initialization."""
# Set a custom startup command
console_widget.startup_cmd = "test startup command"
assert console_widget.startup_cmd == "test startup command"
# Generator to simulate JS initialization sequence
def js_readiness_sequence():
yield False # First call: not ready yet
while True:
yield True # Any subsequent calls: ready
readiness_gen = js_readiness_sequence()
def mock_run_js(script, callback=None):
# Check if this is the initialization check call
if "window.term !== undefined" in script and callback:
ready = next(readiness_gen)
callback(ready)
else:
# For other JavaScript calls (like paste), just call the callback
if callback:
callback(True)
with mock.patch.object(
console_widget.page, "runJavaScript", side_effect=mock_run_js
) as mock_run_js_method:
# Reset initialization state and start the timer
console_widget._is_initialized = False
console_widget._startup_timer.start()
# Wait for the initialization to complete
qtbot.waitUntil(lambda: console_widget._is_initialized, timeout=3000)
# Verify that the startup command was executed
startup_calls = [
call
for call in mock_run_js_method.call_args_list
if "test startup command" in str(call)
]
assert len(startup_calls) > 0, "Startup command should have been executed"
# Verify the initialized signal was emitted
assert console_widget._is_initialized is True
assert not console_widget._startup_timer.isActive()
def test_web_console_set_readonly(console_widget):
# Test the set_readonly method
console_widget.set_readonly(True)
assert not console_widget.isEnabled()
console_widget.set_readonly(False)
assert console_widget.isEnabled()