mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-14 04:30:54 +02:00
Compare commits
23 Commits
prototype/
...
v2.35.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2f8880459 | ||
| 926d722955 | |||
| 44ba7201b4 | |||
|
|
0717426db2 | ||
| f4af6ebc5f | |||
| a923f12c97 | |||
| a5a7607a83 | |||
| 9de548446b | |||
| 49ac7decf7 | |||
|
|
092bed38fa | ||
| 50c84a766a | |||
| d22a3317ba | |||
| 6df1d0c31f | |||
| 946752a4b0 | |||
| c1f62ad6cb | |||
| a5adf3a97d | |||
|
|
76e3e0b60f | ||
| f18eeb9c5d | |||
| 32ce8e2818 | |||
| 23413cffab | |||
|
|
4bbb8fa519 | ||
|
|
a972369a72 | ||
| cd81e7f9ba |
85
CHANGELOG.md
85
CHANGELOG.md
@@ -1,6 +1,91 @@
|
||||
# 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
|
||||
|
||||
@@ -4563,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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
0
bec_widgets/utils/bec_plugin_manager/__init__.py
Normal file
0
bec_widgets/utils/bec_plugin_manager/__init__.py
Normal file
86
bec_widgets/utils/bec_plugin_manager/create/widget.py
Normal file
86
bec_widgets/utils/bec_plugin_manager/create/widget.py
Normal 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)
|
||||
136
bec_widgets/utils/bec_plugin_manager/edit_ui.py
Normal file
136
bec_widgets/utils/bec_plugin_manager/edit_ui.py
Normal 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...")
|
||||
694
bec_widgets/utils/property_editor.py
Normal file
694
bec_widgets/utils/property_editor.py
Normal 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())
|
||||
@@ -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]):
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "2.33.0"
|
||||
version = "2.35.0"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
@@ -20,7 +20,7 @@ dependencies = [
|
||||
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
|
||||
"pydantic~=2.0",
|
||||
"pyqtgraph~=0.13",
|
||||
"PySide6~=6.8.2",
|
||||
"PySide6==6.9.0",
|
||||
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
|
||||
"qtpy~=2.4",
|
||||
"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]
|
||||
|
||||
@@ -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
|
||||
|
||||
255
tests/unit_tests/test_plugin_creator.py
Normal file
255
tests/unit_tests/test_plugin_creator.py
Normal 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
|
||||
586
tests/unit_tests/test_property_editor.py
Normal file
586
tests/unit_tests/test_property_editor.py
Normal 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
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user