mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-10 02:30:54 +02:00
Compare commits
32 Commits
v2.32.0
...
scratch/de
| Author | SHA1 | Date | |
|---|---|---|---|
| fde7b4db6c | |||
|
|
a2f8880459 | ||
| 926d722955 | |||
| 44ba7201b4 | |||
|
|
0717426db2 | ||
| f4af6ebc5f | |||
| a923f12c97 | |||
| a5a7607a83 | |||
| 9de548446b | |||
| 49ac7decf7 | |||
|
|
092bed38fa | ||
| 50c84a766a | |||
| d22a3317ba | |||
| 6df1d0c31f | |||
| 946752a4b0 | |||
| c1f62ad6cb | |||
| a5adf3a97d | |||
|
|
76e3e0b60f | ||
| f18eeb9c5d | |||
| 32ce8e2818 | |||
| 23413cffab | |||
|
|
4bbb8fa519 | ||
|
|
a972369a72 | ||
| cd81e7f9ba | |||
|
|
e2b8118f67 | ||
| 5f925ba4e3 | |||
| fc68d2cf2d | |||
| 627b49b33a | |||
| a51ef04cdf | |||
| 40f4bce285 | |||
| 2b9fe6c959 | |||
| c2e16429c9 |
117
CHANGELOG.md
117
CHANGELOG.md
@@ -1,6 +1,123 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v2.35.0 (2025-08-14)
|
||||
|
||||
### Build System
|
||||
|
||||
- Pyside6 upgraded to 6.9.0
|
||||
([`44ba720`](https://github.com/bec-project/bec_widgets/commit/44ba7201b4914d63281bbed5e62d07e5c240595a))
|
||||
|
||||
### Features
|
||||
|
||||
- **property_manager**: Property manager widget
|
||||
([`926d722`](https://github.com/bec-project/bec_widgets/commit/926d7229559d189d382fe034b3afbc544e709efa))
|
||||
|
||||
|
||||
## v2.34.0 (2025-08-07)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Plugin widget import machinery
|
||||
([`9de5484`](https://github.com/bec-project/bec_widgets/commit/9de548446b9975c0f692757c66ffa07b9a849f15))
|
||||
|
||||
- lazy import client so plugin widgets can import BECWidgets which use it indirectly - exclude
|
||||
classes originating from bec_widgets core from plugin discovery - better errors
|
||||
|
||||
- Use better source for plugin repo name
|
||||
([`f4af6eb`](https://github.com/bec-project/bec_widgets/commit/f4af6ebc5fabf5b62ec87b580476d93d52690b08))
|
||||
|
||||
### Features
|
||||
|
||||
- Autoformat compiled file and add docs
|
||||
([`a923f12`](https://github.com/bec-project/bec_widgets/commit/a923f12c974192909222fcada9eca97325866d74))
|
||||
|
||||
- **plugin manager**: Add cli commands
|
||||
([`49ac7de`](https://github.com/bec-project/bec_widgets/commit/49ac7decf7d4cf461e6437f7285dc6967ee36d96))
|
||||
|
||||
|
||||
## v2.33.3 (2025-07-31)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **scan-history-view**: Account for async loading of scan history
|
||||
([`6df1d0c`](https://github.com/bec-project/bec_widgets/commit/6df1d0c31fb58c25b01e95e2247277ff2dd5d00e))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- Improve scan history performance on loading full scan lists
|
||||
([`a5adf3a`](https://github.com/bec-project/bec_widgets/commit/a5adf3a97d9ff05cef833445c1e6cd8f35a9a2fa))
|
||||
|
||||
- Make ids a set, cleanup
|
||||
([`c1f62ad`](https://github.com/bec-project/bec_widgets/commit/c1f62ad6cb00d9b392a8e0b6247f5260dfb37256))
|
||||
|
||||
- Use client callback for scan history reload
|
||||
([`d22a331`](https://github.com/bec-project/bec_widgets/commit/d22a3317baeccfcc4e074dcef4e3912301d210c5))
|
||||
|
||||
- **scan-history**: Add spinner for loading time of history
|
||||
([`50c84a7`](https://github.com/bec-project/bec_widgets/commit/50c84a766a2b021768fb2c0e8ee00b8e5f058ba7))
|
||||
|
||||
- **scan-history**: Fix insert logic; cleanup
|
||||
([`946752a`](https://github.com/bec-project/bec_widgets/commit/946752a4b05804c2f59cb5c21e4c1d11709a7d44))
|
||||
|
||||
|
||||
## v2.33.2 (2025-07-31)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Delete choice dialog on close
|
||||
([`23413cf`](https://github.com/bec-project/bec_widgets/commit/23413cffabe721e35bb5bb726ec34d74dc4ffe05))
|
||||
|
||||
- Display short lists in SignalDisplay
|
||||
([`4bbb8fa`](https://github.com/bec-project/bec_widgets/commit/4bbb8fa519e8a90eebfcfa34e157493c9baa7880))
|
||||
|
||||
- Don't warn on empty DeviceEdit init
|
||||
([`f18eeb9`](https://github.com/bec-project/bec_widgets/commit/f18eeb9c5dccbd9348b6ee6d1477a8b7925d40fc))
|
||||
|
||||
- Remove config, directly set device+signal
|
||||
([`32ce8e2`](https://github.com/bec-project/bec_widgets/commit/32ce8e2818ceacda87e48399e3ed4df0cabb2335))
|
||||
|
||||
|
||||
## v2.33.1 (2025-07-31)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli**: Ensure guis are not started twice
|
||||
([`cd81e7f`](https://github.com/bec-project/bec_widgets/commit/cd81e7f9ba40be23f6b930d250f743276720b277))
|
||||
|
||||
|
||||
## v2.33.0 (2025-07-29)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **monaco**: Forward text changed signal
|
||||
([`a51ef04`](https://github.com/bec-project/bec_widgets/commit/a51ef04cdf0ac8abdb7008d78b13c75b86ce9e06))
|
||||
|
||||
### Build System
|
||||
|
||||
- Update bec and qtmonaco min dependencies
|
||||
([`5f925ba`](https://github.com/bec-project/bec_widgets/commit/5f925ba4e3840219e4473d6346ece6746076f718))
|
||||
|
||||
### Features
|
||||
|
||||
- **monaco**: Add insert, delete and lsp header
|
||||
([`fc68d2c`](https://github.com/bec-project/bec_widgets/commit/fc68d2cf2d6b161d8e3b9fc9daf6185d9197deba))
|
||||
|
||||
- **monaco**: Add vim mode
|
||||
([`627b49b`](https://github.com/bec-project/bec_widgets/commit/627b49b33a30e45b2bfecb57f090eecfa31af09d))
|
||||
|
||||
- **web console**: Add set_readonly method
|
||||
([`c2e1642`](https://github.com/bec-project/bec_widgets/commit/c2e16429c91de7cc0e672ba36224e9031c1c4234))
|
||||
|
||||
- **web console**: Add signal to indicate when the js backend is initialized
|
||||
([`2b9fe6c`](https://github.com/bec-project/bec_widgets/commit/2b9fe6c9590c8d18b7542307273176e118828681))
|
||||
|
||||
### Testing
|
||||
|
||||
- **web console**: Add tests for the web console
|
||||
([`40f4bce`](https://github.com/bec-project/bec_widgets/commit/40f4bce2854bcf333ce261229bd1703b80ced538))
|
||||
|
||||
|
||||
## v2.32.0 (2025-07-29)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -2469,6 +2469,26 @@ class MonacoWidget(RPCBase):
|
||||
Get the current text from the Monaco editor.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def insert_text(self, text: str, line: int | None = None, column: int | None = None) -> None:
|
||||
"""
|
||||
Insert text at the current cursor position or at a specified line and column.
|
||||
|
||||
Args:
|
||||
text (str): The text to insert.
|
||||
line (int, optional): The line number (1-based) to insert the text at. Defaults to None.
|
||||
column (int, optional): The column number (1-based) to insert the text at. Defaults to None.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def delete_line(self, line: int | None = None) -> None:
|
||||
"""
|
||||
Delete a line in the Monaco editor.
|
||||
|
||||
Args:
|
||||
line (int, optional): The line number (1-based) to delete. If None, the current line will be deleted.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_language(self, language: str) -> None:
|
||||
"""
|
||||
@@ -2542,6 +2562,34 @@ class MonacoWidget(RPCBase):
|
||||
enabled (bool): If True, the minimap will be enabled; otherwise, it will be disabled.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_vim_mode_enabled(self, enabled: bool) -> None:
|
||||
"""
|
||||
Enable or disable Vim mode in the Monaco editor.
|
||||
|
||||
Args:
|
||||
enabled (bool): If True, Vim mode will be enabled; otherwise, it will be disabled.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def set_lsp_header(self, header: str) -> None:
|
||||
"""
|
||||
Set the LSP (Language Server Protocol) header for the Monaco editor.
|
||||
The header is used to provide context for language servers but is not displayed in the editor.
|
||||
|
||||
Args:
|
||||
header (str): The LSP header to set.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def get_lsp_header(self) -> str:
|
||||
"""
|
||||
Get the current LSP header set in the Monaco editor.
|
||||
|
||||
Returns:
|
||||
str: The LSP header.
|
||||
"""
|
||||
|
||||
|
||||
class MotorMap(RPCBase):
|
||||
"""Motor map widget for plotting motor positions in 2D including a trace of the last points."""
|
||||
@@ -4515,6 +4563,20 @@ class SignalLabel(RPCBase):
|
||||
Displays the full data from array signals if set to True.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def max_list_display_len(self) -> "int":
|
||||
"""
|
||||
For small lists, the max length to display
|
||||
"""
|
||||
|
||||
@max_list_display_len.setter
|
||||
@rpc_call
|
||||
def max_list_display_len(self) -> "int":
|
||||
"""
|
||||
For small lists, the max length to display
|
||||
"""
|
||||
|
||||
|
||||
class SignalLineEdit(RPCBase):
|
||||
"""Line edit widget for device input with autocomplete for device names."""
|
||||
|
||||
@@ -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]):
|
||||
|
||||
998
bec_widgets/widgets/control/device_manager/device_manager.py
Normal file
998
bec_widgets/widgets/control/device_manager/device_manager.py
Normal file
@@ -0,0 +1,998 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import QSize, QSortFilterProxyModel, Qt
|
||||
from qtpy.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QSplitter,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QTreeWidget,
|
||||
QTreeWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from thefuzz import fuzz
|
||||
|
||||
from bec_widgets.utils.bec_table import BECTable
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot
|
||||
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
||||
|
||||
|
||||
class CheckBoxCenterWidget(QWidget):
|
||||
"""Widget to center a checkbox in a table cell."""
|
||||
|
||||
def __init__(self, checked=False, parent=None):
|
||||
super().__init__(parent)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setAlignment(Qt.AlignCenter)
|
||||
layout.setContentsMargins(4, 0, 4, 0) # Reduced margins for more compact layout
|
||||
|
||||
self.checkbox = QCheckBox()
|
||||
self.checkbox.setChecked(checked)
|
||||
self.checkbox.setEnabled(False) # Read-only
|
||||
|
||||
# Store the value for sorting
|
||||
self.value = checked
|
||||
|
||||
layout.addWidget(self.checkbox)
|
||||
|
||||
|
||||
class TextLabelWidget(QWidget):
|
||||
"""Widget to display text with word wrapping in a table cell."""
|
||||
|
||||
def __init__(self, text="", parent=None):
|
||||
super().__init__(parent)
|
||||
# Use a layout with minimal margins to maximize text display area
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(2, 2, 2, 2)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Create label with word wrap enabled
|
||||
self.label = QLabel(text)
|
||||
self.label.setWordWrap(True)
|
||||
self.label.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
self.label.setAlignment(Qt.AlignLeft | Qt.AlignTop) # Align to top-left
|
||||
|
||||
# Make sure label expands to fill available space
|
||||
self.label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
|
||||
# Store the text value for sorting
|
||||
self.value = text
|
||||
|
||||
layout.addWidget(self.label)
|
||||
|
||||
# Make sure the widget itself uses an expanding size policy
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.MinimumExpanding)
|
||||
|
||||
# Ensure we have a reasonable height to start with
|
||||
# This helps ensure text is visible before resizing calculations
|
||||
min_height = 40 if text else 20
|
||||
self.setMinimumHeight(min_height)
|
||||
|
||||
def setText(self, text):
|
||||
"""Set the text of the label."""
|
||||
self.label.setText(text)
|
||||
self.value = text
|
||||
# Trigger layout update
|
||||
self.updateGeometry()
|
||||
|
||||
def sizeHint(self):
|
||||
"""Provide a size hint based on the text content."""
|
||||
# Get the width of our container (usually the table cell)
|
||||
width = self.width() or 300
|
||||
|
||||
# If text is empty, return minimal size
|
||||
if not self.value:
|
||||
return QSize(width, 20)
|
||||
|
||||
# Calculate height for wrapped text
|
||||
font_metrics = self.label.fontMetrics()
|
||||
|
||||
# Estimate how much space the text will need when wrapped
|
||||
text_rect = font_metrics.boundingRect(
|
||||
0,
|
||||
0,
|
||||
width - 10,
|
||||
1000, # Width constraint, virtually unlimited height
|
||||
Qt.TextWordWrap,
|
||||
self.value,
|
||||
)
|
||||
|
||||
# Add some padding
|
||||
height = text_rect.height() + 8
|
||||
|
||||
return QSize(width, max(30, height))
|
||||
|
||||
def resizeEvent(self, event):
|
||||
"""Handle resize events to ensure text is properly displayed."""
|
||||
super().resizeEvent(event)
|
||||
|
||||
# When resized (especially width change), update layout to ensure text wrapping works
|
||||
self.label.updateGeometry()
|
||||
self.updateGeometry()
|
||||
|
||||
|
||||
class SortableTableWidgetItem(QTableWidgetItem):
|
||||
"""Table widget item that enables proper sorting for different data types."""
|
||||
|
||||
def __lt__(self, other):
|
||||
"""Compare items for sorting."""
|
||||
if self.text() == "Yes" and other.text() == "No":
|
||||
return True
|
||||
elif self.text() == "No" and other.text() == "Yes":
|
||||
return False
|
||||
else:
|
||||
return self.text().lower() < other.text().lower()
|
||||
|
||||
|
||||
class DeviceTagsWidget(BECWidget, QWidget):
|
||||
"""Widget to display devices grouped by their tags in containers."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.setLayout(self.layout)
|
||||
|
||||
# Title
|
||||
self.title_label = QLabel("Device Tags")
|
||||
self.title_label.setStyleSheet("font-weight: bold; font-size: 14px;")
|
||||
self.layout.addWidget(self.title_label)
|
||||
|
||||
# Search bar for tags
|
||||
self.search_layout = QHBoxLayout()
|
||||
self.search_label = QLabel("Search:")
|
||||
self.search_input = QLineEdit()
|
||||
self.search_input.setPlaceholderText("Filter tags...")
|
||||
self.search_input.setClearButtonEnabled(True)
|
||||
self.search_input.textChanged.connect(self.filter_tags)
|
||||
self.search_layout.addWidget(self.search_label)
|
||||
self.search_layout.addWidget(self.search_input)
|
||||
self.layout.addLayout(self.search_layout)
|
||||
|
||||
# Create a scroll area for tag containers
|
||||
self.scroll_area = QScrollArea()
|
||||
self.scroll_area.setWidgetResizable(True)
|
||||
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
|
||||
# Create a widget to hold all tag containers
|
||||
self.scroll_widget = QWidget()
|
||||
self.scroll_layout = QVBoxLayout(self.scroll_widget)
|
||||
self.scroll_layout.setSpacing(10)
|
||||
self.scroll_layout.setContentsMargins(5, 5, 5, 5)
|
||||
self.scroll_layout.setAlignment(Qt.AlignTop)
|
||||
|
||||
self.scroll_area.setWidget(self.scroll_widget)
|
||||
self.layout.addWidget(self.scroll_area)
|
||||
|
||||
# Initialize with empty data
|
||||
self.all_devices = []
|
||||
self.active_devices = []
|
||||
self.device_tags = {} # Maps tag names to lists of device names
|
||||
self.tag_containers = {} # Maps tag names to their container widgets
|
||||
|
||||
# Load initial data
|
||||
self.update_tags()
|
||||
|
||||
def update_tags(self):
|
||||
"""Update the tags containers with current device information."""
|
||||
try:
|
||||
# Get device config
|
||||
config = self.client.device_manager._get_redis_device_config()
|
||||
|
||||
# Clear current data
|
||||
self.all_devices = []
|
||||
self.active_devices = []
|
||||
self.device_tags = {}
|
||||
|
||||
# Process device config
|
||||
for device_info in config:
|
||||
device_name = device_info.get("name", "Unknown")
|
||||
self.all_devices.append(device_name)
|
||||
|
||||
# Add to active devices if enabled
|
||||
if device_info.get("enabled", False):
|
||||
self.active_devices.append(device_name)
|
||||
|
||||
# Process device tags
|
||||
tags = device_info.get("deviceTags", [])
|
||||
for tag in tags:
|
||||
if tag not in self.device_tags:
|
||||
self.device_tags[tag] = []
|
||||
self.device_tags[tag].append(device_name)
|
||||
|
||||
# Update the tag containers
|
||||
self.populate_tag_containers()
|
||||
|
||||
except Exception as e:
|
||||
ErrorPopupUtility().show_error_message(
|
||||
"Device Tags Error", f"Error updating device tags: {str(e)}", self
|
||||
)
|
||||
|
||||
def populate_tag_containers(self):
|
||||
"""Populate the containers with current tag and device data."""
|
||||
# Save current filter before clearing
|
||||
current_filter = self.search_input.text() if hasattr(self, "search_input") else ""
|
||||
|
||||
# Clear existing containers
|
||||
for i in reversed(range(self.scroll_layout.count())):
|
||||
widget = self.scroll_layout.itemAt(i).widget()
|
||||
if widget:
|
||||
widget.setParent(None)
|
||||
widget.deleteLater()
|
||||
|
||||
self.tag_containers = {}
|
||||
|
||||
# Add tag containers
|
||||
for tag, devices in sorted(self.device_tags.items()):
|
||||
# Create container frame for this tag
|
||||
container = QFrame()
|
||||
container.setFrameStyle(QFrame.StyledPanel | QFrame.Raised)
|
||||
container.setStyleSheet(
|
||||
"QFrame { background-color: palette(window); border: 1px solid palette(mid); border-radius: 4px; }"
|
||||
)
|
||||
|
||||
container_layout = QVBoxLayout(container)
|
||||
container_layout.setContentsMargins(10, 10, 10, 10)
|
||||
|
||||
# Add tag header with status indicator
|
||||
header_layout = QHBoxLayout()
|
||||
|
||||
# Tag name label
|
||||
tag_label = QLabel(tag)
|
||||
tag_label.setStyleSheet("font-weight: bold;")
|
||||
header_layout.addWidget(tag_label)
|
||||
|
||||
# Spacer to push status to the right
|
||||
header_layout.addStretch()
|
||||
|
||||
# Status indicator
|
||||
all_devices_count = len(devices)
|
||||
active_devices_count = sum(1 for d in devices if d in self.active_devices)
|
||||
|
||||
if active_devices_count == 0:
|
||||
status_text = "None"
|
||||
status_color = "red"
|
||||
elif active_devices_count == all_devices_count:
|
||||
status_text = "All"
|
||||
status_color = "green"
|
||||
else:
|
||||
status_text = f"{active_devices_count}/{all_devices_count}"
|
||||
status_color = "orange"
|
||||
|
||||
status_label = QLabel(status_text)
|
||||
status_label.setStyleSheet(f"color: {status_color}; font-weight: bold;")
|
||||
header_layout.addWidget(status_label)
|
||||
|
||||
container_layout.addLayout(header_layout)
|
||||
|
||||
# Add divider line
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.HLine)
|
||||
line.setFrameShadow(QFrame.Sunken)
|
||||
container_layout.addWidget(line)
|
||||
|
||||
# Add device list
|
||||
device_list = QListWidget()
|
||||
device_list.setAlternatingRowColors(True)
|
||||
device_list.setMaximumHeight(150) # Limit height
|
||||
|
||||
# Add devices to the list
|
||||
for device_name in sorted(devices):
|
||||
item = QListWidgetItem(device_name)
|
||||
if device_name in self.active_devices:
|
||||
item.setForeground(Qt.green)
|
||||
else:
|
||||
item.setForeground(Qt.red)
|
||||
device_list.addItem(item)
|
||||
|
||||
container_layout.addWidget(device_list)
|
||||
|
||||
# Add to the scroll layout
|
||||
self.scroll_layout.addWidget(container)
|
||||
self.tag_containers[tag] = container
|
||||
|
||||
# Add a stretch at the end to push all containers to the top
|
||||
self.scroll_layout.addStretch()
|
||||
|
||||
# Reapply filter if there was one
|
||||
if current_filter:
|
||||
self.filter_tags(current_filter)
|
||||
|
||||
@SafeSlot(str)
|
||||
def filter_tags(self, text):
|
||||
"""Filter the tag containers based on search text."""
|
||||
if not hasattr(self, "tag_containers"):
|
||||
return
|
||||
|
||||
text = text.lower()
|
||||
|
||||
# Show/hide tag containers based on filter
|
||||
for tag, container in self.tag_containers.items():
|
||||
if not text or text in tag.lower():
|
||||
# Tag matches filter
|
||||
container.show()
|
||||
else:
|
||||
# Check if any device in this tag matches
|
||||
matches = False
|
||||
for device in self.device_tags.get(tag, []):
|
||||
if text in device.lower():
|
||||
matches = True
|
||||
break
|
||||
|
||||
container.setVisible(matches)
|
||||
|
||||
@SafeSlot()
|
||||
def add_devices_by_tag(self):
|
||||
"""Add devices with the selected tags to the active configuration."""
|
||||
# This would be implemented for drag-and-drop in the future
|
||||
|
||||
@SafeSlot()
|
||||
def remove_devices_by_tag(self):
|
||||
"""Remove devices with the selected tags from the active configuration."""
|
||||
# This would be implemented for drag-and-drop in the future
|
||||
|
||||
|
||||
class DeviceManager(BECWidget, QWidget):
|
||||
"""Widget to display the current device configuration in a table."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
# Main layout for the entire widget
|
||||
self.main_layout = QHBoxLayout(self)
|
||||
self.setLayout(self.main_layout)
|
||||
|
||||
# Create a splitter to hold the device tags widget and device table
|
||||
self.splitter = QSplitter(Qt.Horizontal)
|
||||
|
||||
# Create device tags widget
|
||||
self.device_tags_widget = DeviceTagsWidget(self)
|
||||
self.splitter.addWidget(self.device_tags_widget)
|
||||
|
||||
# Create container for device table and its controls
|
||||
self.table_container = QWidget()
|
||||
self.layout = QVBoxLayout(self.table_container)
|
||||
|
||||
# Create search bar
|
||||
self.search_layout = QHBoxLayout()
|
||||
self.search_label = QLabel("Search:")
|
||||
self.search_input = QLineEdit()
|
||||
self.search_input.setPlaceholderText(
|
||||
"Filter devices (approximate matching)..."
|
||||
) # Default to fuzzy search
|
||||
self.search_input.setClearButtonEnabled(True)
|
||||
self.search_input.textChanged.connect(self.filter_devices)
|
||||
self.search_layout.addWidget(self.search_label)
|
||||
self.search_layout.addWidget(self.search_input)
|
||||
|
||||
# Add exact match toggle
|
||||
self.fuzzy_toggle_layout = QHBoxLayout()
|
||||
self.fuzzy_toggle_label = QLabel("Exact Match:")
|
||||
self.fuzzy_toggle = ToggleSwitch()
|
||||
self.fuzzy_toggle.setChecked(False) # Default to fuzzy search (toggle OFF)
|
||||
self.fuzzy_toggle.stateChanged.connect(self.on_fuzzy_toggle_changed)
|
||||
self.fuzzy_toggle.setToolTip(
|
||||
"Toggle between approximate matching (OFF) and exact matching (ON)"
|
||||
)
|
||||
self.fuzzy_toggle_label.setToolTip(
|
||||
"Toggle between approximate matching (OFF) and exact matching (ON)"
|
||||
)
|
||||
self.fuzzy_toggle_layout.addWidget(self.fuzzy_toggle_label)
|
||||
self.fuzzy_toggle_layout.addWidget(self.fuzzy_toggle)
|
||||
self.fuzzy_toggle_layout.addStretch()
|
||||
|
||||
# Add both search components to the layout
|
||||
self.search_controls = QHBoxLayout()
|
||||
self.search_controls.addLayout(self.search_layout)
|
||||
self.search_controls.addSpacing(20) # Add some space between the search box and toggle
|
||||
self.search_controls.addLayout(self.fuzzy_toggle_layout)
|
||||
|
||||
self.layout.addLayout(self.search_controls)
|
||||
|
||||
# Create table widget
|
||||
self.device_table = BECTable()
|
||||
self.device_table.setEditTriggers(QTableWidget.NoEditTriggers) # Make table read-only
|
||||
self.device_table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
self.device_table.setAlternatingRowColors(True)
|
||||
self.layout.addWidget(self.device_table)
|
||||
|
||||
# Connect custom sorting handler
|
||||
self.device_table.horizontalHeader().sectionClicked.connect(self.handle_header_click)
|
||||
self.current_sort_section = 0
|
||||
self.current_sort_order = Qt.AscendingOrder
|
||||
|
||||
# Make table resizable
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.device_table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
|
||||
# Don't stretch the last section to prevent it from changing width
|
||||
self.device_table.horizontalHeader().setStretchLastSection(False)
|
||||
self.device_table.verticalHeader().setVisible(False)
|
||||
|
||||
# Set up initial headers
|
||||
self.headers = [
|
||||
"Name",
|
||||
"Device Class",
|
||||
"Readout Priority",
|
||||
"Enabled",
|
||||
"Read Only",
|
||||
"Documentation",
|
||||
]
|
||||
self.device_table.setColumnCount(len(self.headers))
|
||||
self.device_table.setHorizontalHeaderLabels(self.headers)
|
||||
|
||||
# Set initial column resize modes
|
||||
header = self.device_table.horizontalHeader()
|
||||
header.setSectionResizeMode(0, QHeaderView.ResizeToContents) # Name
|
||||
header.setSectionResizeMode(1, QHeaderView.ResizeToContents) # Device Class
|
||||
header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Readout Priority
|
||||
header.setSectionResizeMode(3, QHeaderView.Fixed) # Enabled
|
||||
header.setSectionResizeMode(4, QHeaderView.Fixed) # Read Only
|
||||
header.setSectionResizeMode(5, QHeaderView.Stretch) # Documentation
|
||||
|
||||
# Connect resize signal to adjust row heights when table is resized
|
||||
self.device_table.horizontalHeader().sectionResized.connect(self.on_table_resized)
|
||||
|
||||
# Set fixed width for checkbox columns
|
||||
self.device_table.setColumnWidth(3, 70) # Enabled column
|
||||
self.device_table.setColumnWidth(4, 70) # Read Only column
|
||||
|
||||
# Ensure column widths stay fixed
|
||||
header.setMinimumSectionSize(70)
|
||||
header.setDefaultSectionSize(100)
|
||||
|
||||
# Enable sorting by clicking column headers
|
||||
self.device_table.setSortingEnabled(False) # We'll handle sorting manually
|
||||
self.device_table.horizontalHeader().setSortIndicatorShown(True)
|
||||
self.device_table.horizontalHeader().setSectionsClickable(True)
|
||||
|
||||
# Add buttons for adding/removing devices
|
||||
self.button_layout = QHBoxLayout()
|
||||
|
||||
# Add device button
|
||||
self.add_device_button = QPushButton("Add Device")
|
||||
self.add_device_button.clicked.connect(self.add_device)
|
||||
self.button_layout.addWidget(self.add_device_button)
|
||||
|
||||
# Remove device button
|
||||
self.remove_device_button = QPushButton("Remove Device")
|
||||
self.remove_device_button.clicked.connect(self.remove_device)
|
||||
self.button_layout.addWidget(self.remove_device_button)
|
||||
|
||||
# Add buttons to main layout
|
||||
self.layout.addLayout(self.button_layout)
|
||||
|
||||
# Add the table container to the splitter
|
||||
self.splitter.addWidget(self.table_container)
|
||||
|
||||
# Set initial sizes (30% for tags, 70% for table)
|
||||
self.splitter.setSizes([300, 700])
|
||||
|
||||
# Add the splitter to the main layout
|
||||
self.main_layout.addWidget(self.splitter)
|
||||
|
||||
# Connect signals between widgets
|
||||
self.connect_signals()
|
||||
|
||||
# Load initial data
|
||||
self.update_device_table()
|
||||
|
||||
def connect_signals(self):
|
||||
"""Connect signals between the device table and tags widget."""
|
||||
# Connect add devices by tag button to update the table
|
||||
if hasattr(self.device_tags_widget, "add_tag_button"):
|
||||
self.device_tags_widget.add_tag_button.clicked.connect(self.update_device_table)
|
||||
|
||||
# Connect remove devices by tag button to update the table
|
||||
if hasattr(self.device_tags_widget, "remove_tag_button"):
|
||||
self.device_tags_widget.remove_tag_button.clicked.connect(self.update_device_table)
|
||||
|
||||
@SafeSlot(int, int, int)
|
||||
def on_table_resized(self, column, old_width, new_width):
|
||||
"""Handle table column resize events to readjust row heights for text wrapping."""
|
||||
# Only handle resizes of the documentation column
|
||||
if column == 5:
|
||||
# Update all rows with TextLabelWidgets in the documentation column
|
||||
for row in range(self.device_table.rowCount()):
|
||||
doc_widget = self.device_table.cellWidget(row, 5)
|
||||
if doc_widget and isinstance(doc_widget, TextLabelWidget):
|
||||
# Trigger recalculation of text wrapping
|
||||
doc_widget.updateGeometry()
|
||||
|
||||
# Force the table to recalculate row heights
|
||||
if doc_widget.value:
|
||||
# Get text metrics
|
||||
font_metrics = doc_widget.label.fontMetrics()
|
||||
|
||||
# Calculate new text height with word wrap
|
||||
text_rect = font_metrics.boundingRect(
|
||||
0,
|
||||
0,
|
||||
new_width - 10,
|
||||
2000, # New width constraint
|
||||
Qt.TextWordWrap,
|
||||
doc_widget.value,
|
||||
)
|
||||
|
||||
# Update row height
|
||||
row_height = text_rect.height() + 16
|
||||
self.device_table.setRowHeight(row, max(40, row_height))
|
||||
|
||||
@SafeSlot()
|
||||
def update_device_table(self):
|
||||
"""Update the device table with the current device configuration."""
|
||||
try:
|
||||
# Get device config (always a list of dictionaries)
|
||||
config = self.client.device_manager._get_redis_device_config()
|
||||
|
||||
# Clear existing rows
|
||||
self.device_table.setRowCount(0)
|
||||
|
||||
# Add devices to the table
|
||||
for device_info in config:
|
||||
row_position = self.device_table.rowCount()
|
||||
self.device_table.insertRow(row_position)
|
||||
|
||||
# Set device name
|
||||
self.device_table.setItem(
|
||||
row_position, 0, SortableTableWidgetItem(device_info.get("name", "Unknown"))
|
||||
)
|
||||
|
||||
# Set device class
|
||||
device_class = device_info.get("deviceClass", "Unknown")
|
||||
self.device_table.setItem(row_position, 1, SortableTableWidgetItem(device_class))
|
||||
|
||||
# Set readout priority
|
||||
readout_priority = device_info.get("readoutPriority", "Unknown")
|
||||
self.device_table.setItem(
|
||||
row_position, 2, SortableTableWidgetItem(readout_priority)
|
||||
)
|
||||
|
||||
# Set enabled status as checkbox
|
||||
enabled_checkbox = CheckBoxCenterWidget(device_info.get("enabled", False))
|
||||
self.device_table.setCellWidget(row_position, 3, enabled_checkbox)
|
||||
|
||||
# Set read-only status as checkbox
|
||||
readonly_checkbox = CheckBoxCenterWidget(device_info.get("readOnly", False))
|
||||
self.device_table.setCellWidget(row_position, 4, readonly_checkbox)
|
||||
|
||||
# Set documentation using text label widget with word wrap
|
||||
documentation = device_info.get("documentation", "")
|
||||
doc_widget = TextLabelWidget(documentation)
|
||||
self.device_table.setCellWidget(row_position, 5, doc_widget)
|
||||
|
||||
# First, ensure the table is updated to show the new widgets
|
||||
self.device_table.viewport().update()
|
||||
|
||||
# Force a layout update to get proper sizes
|
||||
self.device_table.resizeRowsToContents()
|
||||
|
||||
# Then adjust row heights with better calculation for wrapped text
|
||||
for row in range(self.device_table.rowCount()):
|
||||
doc_widget = self.device_table.cellWidget(row, 5)
|
||||
if doc_widget and isinstance(doc_widget, TextLabelWidget):
|
||||
text = doc_widget.value
|
||||
if text:
|
||||
# Get the column width
|
||||
col_width = self.device_table.columnWidth(5)
|
||||
|
||||
# Calculate appropriate height for the text
|
||||
font_metrics = doc_widget.label.fontMetrics()
|
||||
|
||||
# Calculate text rectangle with word wrap
|
||||
text_rect = font_metrics.boundingRect(
|
||||
0,
|
||||
0,
|
||||
col_width - 10,
|
||||
2000, # Width constraint with large height
|
||||
Qt.TextWordWrap,
|
||||
text,
|
||||
)
|
||||
|
||||
# Set row height with additional padding
|
||||
row_height = text_rect.height() + 16
|
||||
self.device_table.setRowHeight(row, max(40, row_height))
|
||||
|
||||
# Update the widget to reflect the new size
|
||||
doc_widget.updateGeometry()
|
||||
|
||||
# Apply current sort if any
|
||||
if hasattr(self, "current_sort_section") and self.current_sort_section >= 0:
|
||||
self.sort_table(self.current_sort_section, self.current_sort_order)
|
||||
self.device_table.horizontalHeader().setSortIndicator(
|
||||
self.current_sort_section, self.current_sort_order
|
||||
)
|
||||
|
||||
# Reset the filter to make sure search works with new data
|
||||
if hasattr(self, "search_input"):
|
||||
current_filter = self.search_input.text()
|
||||
if current_filter:
|
||||
self.filter_devices(current_filter)
|
||||
|
||||
# Update the device tags widget
|
||||
self.device_tags_widget.update_tags()
|
||||
|
||||
except Exception as e:
|
||||
ErrorPopupUtility().show_error_message(
|
||||
"Device Manager Error", f"Error updating device table: {str(e)}", self
|
||||
)
|
||||
|
||||
@SafeSlot(bool)
|
||||
def on_fuzzy_toggle_changed(self, enabled):
|
||||
"""
|
||||
Handle exact match toggle state change.
|
||||
|
||||
When toggle is ON (enabled=True): Use exact matching
|
||||
When toggle is OFF (enabled=False): Use fuzzy/approximate matching
|
||||
"""
|
||||
# Update search mode label
|
||||
if hasattr(self, "search_input"):
|
||||
# Store original stylesheet to restore it later
|
||||
original_style = self.search_input.styleSheet()
|
||||
|
||||
# Set placeholder text based on mode
|
||||
if enabled: # Toggle ON = Exact match
|
||||
self.search_input.setPlaceholderText("Filter devices (exact match)...")
|
||||
print("Toggle switched ON: Using EXACT match mode")
|
||||
else: # Toggle OFF = Approximate/fuzzy match
|
||||
self.search_input.setPlaceholderText("Filter devices (approximate matching)...")
|
||||
print("Toggle switched OFF: Using FUZZY match mode")
|
||||
|
||||
# Visual feedback - briefly highlight the search box with appropriate color
|
||||
highlight_color = "#3498db" # Blue for feedback
|
||||
self.search_input.setStyleSheet(f"border: 2px solid {highlight_color};")
|
||||
|
||||
# Create a one-time timer to restore the original style after a short delay
|
||||
from qtpy.QtCore import QTimer
|
||||
|
||||
QTimer.singleShot(500, lambda: self.search_input.setStyleSheet(original_style))
|
||||
|
||||
# Log the toggle state for debugging
|
||||
print(
|
||||
f"Search mode changed: Exact match = {enabled}, Toggle isChecked = {self.fuzzy_toggle.isChecked()}"
|
||||
)
|
||||
|
||||
# When toggle changes, reapply current search with new mode
|
||||
current_text = self.search_input.text()
|
||||
|
||||
# Always reapply the filter, even if text is empty
|
||||
# This ensures all rows are properly shown/hidden based on the new mode
|
||||
self.filter_devices(current_text)
|
||||
|
||||
@SafeSlot(str)
|
||||
def filter_devices(self, text):
|
||||
"""Filter devices in the table based on exact or approximate matching."""
|
||||
# Always show all rows when search is empty, regardless of match mode
|
||||
if not text:
|
||||
for row in range(self.device_table.rowCount()):
|
||||
self.device_table.setRowHidden(row, False)
|
||||
return
|
||||
|
||||
# Get current search mode
|
||||
# When toggle is ON, we use exact match
|
||||
# When toggle is OFF, we use fuzzy/approximate match
|
||||
use_exact_match = hasattr(self, "fuzzy_toggle") and self.fuzzy_toggle.isChecked()
|
||||
|
||||
# Debug print to verify which mode is being used
|
||||
print(f"Filtering with exact match: {use_exact_match}, search text: '{text}'")
|
||||
|
||||
# Threshold for fuzzy matching (0-100, higher is more strict)
|
||||
threshold = 80
|
||||
|
||||
# Prepare search text (lowercase for case-insensitive search)
|
||||
search_text = text.lower()
|
||||
|
||||
# Count of matched rows for feedback (but avoid double-counting)
|
||||
visible_rows = 0
|
||||
total_rows = self.device_table.rowCount()
|
||||
|
||||
# Filter rows using either exact or approximate matching
|
||||
for row in range(total_rows):
|
||||
row_visible = False
|
||||
|
||||
# Check name and device class columns (0 and 1)
|
||||
for col in [0, 1]: # Name and Device Class columns
|
||||
item = self.device_table.item(row, col)
|
||||
if not item:
|
||||
continue
|
||||
|
||||
cell_text = item.text().lower()
|
||||
|
||||
if use_exact_match:
|
||||
# EXACT MATCH: Simple substring check
|
||||
if search_text in cell_text:
|
||||
row_visible = True
|
||||
break
|
||||
else:
|
||||
# FUZZY MATCH: Use approximate matching
|
||||
match_ratio = fuzz.partial_ratio(search_text, cell_text)
|
||||
if match_ratio >= threshold:
|
||||
row_visible = True
|
||||
break
|
||||
|
||||
# Hide or show this row
|
||||
self.device_table.setRowHidden(row, not row_visible)
|
||||
|
||||
# Count visible rows for potential feedback
|
||||
if row_visible:
|
||||
visible_rows += 1
|
||||
|
||||
@SafeSlot(int)
|
||||
def handle_header_click(self, section):
|
||||
"""Handle column header click to sort the table."""
|
||||
# Toggle sort order if clicking the same section
|
||||
if section == self.current_sort_section:
|
||||
self.current_sort_order = (
|
||||
Qt.DescendingOrder
|
||||
if self.current_sort_order == Qt.AscendingOrder
|
||||
else Qt.AscendingOrder
|
||||
)
|
||||
else:
|
||||
self.current_sort_section = section
|
||||
self.current_sort_order = Qt.AscendingOrder
|
||||
|
||||
# Update sort indicator
|
||||
self.device_table.horizontalHeader().setSortIndicator(
|
||||
self.current_sort_section, self.current_sort_order
|
||||
)
|
||||
|
||||
# Perform the sort
|
||||
self.sort_table(section, self.current_sort_order)
|
||||
|
||||
def sort_table(self, column, order):
|
||||
"""Sort the table by the specified column and order."""
|
||||
row_count = self.device_table.rowCount()
|
||||
if row_count <= 1:
|
||||
return # Nothing to sort
|
||||
|
||||
# Collect all rows for sorting
|
||||
rows_data = []
|
||||
for row in range(row_count):
|
||||
# Create a safe copy of the row data
|
||||
row_data = {}
|
||||
row_data["items"] = []
|
||||
row_data["widgets"] = []
|
||||
row_data["hidden"] = self.device_table.isRowHidden(row)
|
||||
row_data["sort_key"] = None
|
||||
|
||||
# Extract sort key for this row
|
||||
if column in [3, 4]: # Checkbox columns
|
||||
widget = self.device_table.cellWidget(row, column)
|
||||
if widget and hasattr(widget, "value"):
|
||||
row_data["sort_key"] = widget.value
|
||||
else:
|
||||
row_data["sort_key"] = False
|
||||
else: # Text columns
|
||||
item = self.device_table.item(row, column)
|
||||
if item:
|
||||
row_data["sort_key"] = item.text().lower()
|
||||
else:
|
||||
row_data["sort_key"] = ""
|
||||
|
||||
# Collect all items and widgets in the row
|
||||
for col in range(self.device_table.columnCount()):
|
||||
if col in [3, 4]: # Checkbox columns
|
||||
widget = self.device_table.cellWidget(row, col)
|
||||
if widget:
|
||||
# Store the widget value to recreate it
|
||||
is_checked = False
|
||||
if hasattr(widget, "value"):
|
||||
is_checked = widget.value
|
||||
elif hasattr(widget, "checkbox"):
|
||||
is_checked = widget.checkbox.isChecked()
|
||||
row_data["widgets"].append((col, "checkbox", is_checked))
|
||||
elif col == 5: # Documentation column with TextLabelWidget
|
||||
widget = self.device_table.cellWidget(row, col)
|
||||
if widget and isinstance(widget, TextLabelWidget):
|
||||
text = widget.value
|
||||
row_data["widgets"].append((col, "textlabel", text))
|
||||
else:
|
||||
row_data["widgets"].append((col, "textlabel", ""))
|
||||
else:
|
||||
item = self.device_table.item(row, col)
|
||||
if item:
|
||||
row_data["items"].append((col, item.text()))
|
||||
else:
|
||||
row_data["items"].append((col, ""))
|
||||
|
||||
rows_data.append(row_data)
|
||||
|
||||
# Sort the rows
|
||||
reverse = order == Qt.DescendingOrder
|
||||
sorted_rows = sorted(rows_data, key=lambda x: x["sort_key"], reverse=reverse)
|
||||
|
||||
# Rebuild the table with sorted data
|
||||
self.device_table.setUpdatesEnabled(False) # Disable updates while rebuilding
|
||||
|
||||
# Clear and rebuild the table
|
||||
self.device_table.clearContents()
|
||||
self.device_table.setRowCount(row_count)
|
||||
|
||||
for row, row_data in enumerate(sorted_rows):
|
||||
# Add text items
|
||||
for col, text in row_data["items"]:
|
||||
self.device_table.setItem(row, col, SortableTableWidgetItem(text))
|
||||
|
||||
# Add widgets
|
||||
for col, widget_type, value in row_data["widgets"]:
|
||||
if widget_type == "checkbox":
|
||||
checkbox = CheckBoxCenterWidget(value)
|
||||
self.device_table.setCellWidget(row, col, checkbox)
|
||||
elif widget_type == "textlabel":
|
||||
text_label = TextLabelWidget(value)
|
||||
self.device_table.setCellWidget(row, col, text_label)
|
||||
|
||||
# Restore hidden state
|
||||
self.device_table.setRowHidden(row, row_data["hidden"])
|
||||
|
||||
self.device_table.setUpdatesEnabled(True) # Re-enable updates
|
||||
|
||||
@SafeSlot()
|
||||
def show_add_device_dialog(self):
|
||||
"""Show the dialog for adding a new device."""
|
||||
# Call the add_device method to handle the dialog and logic
|
||||
self.add_device()
|
||||
|
||||
@SafeSlot()
|
||||
def add_device(self):
|
||||
"""Simulate adding a new device to the configuration."""
|
||||
try:
|
||||
# Create and show the add device dialog
|
||||
dialog = AddDeviceDialog(self)
|
||||
if dialog.exec():
|
||||
# Get device config from dialog
|
||||
device_config = dialog.get_device_config()
|
||||
device_name = device_config.get("name")
|
||||
|
||||
# Print the action that would be taken (simulation only)
|
||||
print(f"Would add device: {device_name} with config: {device_config}")
|
||||
|
||||
# Show simulation message
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Device Addition Simulated",
|
||||
f"Would add device: {device_name} (simulation only)",
|
||||
)
|
||||
|
||||
# Update the device tags widget
|
||||
self.device_tags_widget.update_tags()
|
||||
|
||||
except Exception as e:
|
||||
ErrorPopupUtility().show_error_message(
|
||||
"Device Manager Error", f"Error in add device simulation: {str(e)}", self
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def remove_device(self):
|
||||
"""Simulate removing selected device(s) from the configuration."""
|
||||
selected_rows = self.device_table.selectionModel().selectedRows()
|
||||
|
||||
if not selected_rows:
|
||||
QMessageBox.information(self, "No Selection", "Please select a device to remove.")
|
||||
return
|
||||
|
||||
# Confirm deletion
|
||||
device_count = len(selected_rows)
|
||||
message = f"Are you sure you want to remove {device_count} device{'s' if device_count > 1 else ''}?"
|
||||
confirmation = QMessageBox.question(
|
||||
self, "Confirm Removal", message, QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
|
||||
if confirmation == QMessageBox.Yes:
|
||||
try:
|
||||
# Get device names from selected rows
|
||||
device_names = []
|
||||
for index in selected_rows:
|
||||
row = index.row()
|
||||
device_name = self.device_table.item(row, 0).text()
|
||||
device_names.append(device_name)
|
||||
|
||||
# Print removal action instead of actual removal
|
||||
print(f"Would remove devices: {device_names}")
|
||||
|
||||
# Show simulation message
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Device Removal Simulated",
|
||||
f"Would remove {device_count} device{'s' if device_count > 1 else ''} (simulation only)",
|
||||
)
|
||||
|
||||
# Update the device tags widget
|
||||
self.device_tags_widget.update_tags()
|
||||
|
||||
except Exception as e:
|
||||
ErrorPopupUtility().show_error_message(
|
||||
"Device Manager Error", f"Error in remove device simulation: {str(e)}", self
|
||||
)
|
||||
|
||||
|
||||
class AddDeviceDialog(QDialog):
|
||||
"""Dialog for adding a new device to the configuration."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Add New Device")
|
||||
self.setMinimumWidth(400)
|
||||
|
||||
# Create layout
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.form_layout = QFormLayout()
|
||||
|
||||
# Device name
|
||||
self.name_input = QLineEdit()
|
||||
self.form_layout.addRow("Device Name:", self.name_input)
|
||||
|
||||
# Device class
|
||||
self.device_class_input = QLineEdit()
|
||||
self.device_class_input.setText("ophyd_devices.SimPositioner")
|
||||
self.form_layout.addRow("Device Class:", self.device_class_input)
|
||||
|
||||
# Readout priority
|
||||
self.readout_priority_combo = QComboBox()
|
||||
self.readout_priority_combo.addItems(["baseline", "monitored", "async", "on_request"])
|
||||
self.form_layout.addRow("Readout Priority:", self.readout_priority_combo)
|
||||
|
||||
# Enabled checkbox
|
||||
self.enabled_checkbox = QCheckBox()
|
||||
self.enabled_checkbox.setChecked(True)
|
||||
self.form_layout.addRow("Enabled:", self.enabled_checkbox)
|
||||
|
||||
# Read-only checkbox
|
||||
self.readonly_checkbox = QCheckBox()
|
||||
self.form_layout.addRow("Read Only:", self.readonly_checkbox)
|
||||
|
||||
# Documentation text
|
||||
self.documentation_input = QLineEdit()
|
||||
self.form_layout.addRow("Documentation:", self.documentation_input)
|
||||
|
||||
# Add form to layout
|
||||
self.layout.addLayout(self.form_layout)
|
||||
|
||||
# Add buttons
|
||||
self.button_layout = QHBoxLayout()
|
||||
self.cancel_button = QPushButton("Cancel")
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
self.add_button = QPushButton("Add Device")
|
||||
self.add_button.clicked.connect(self.accept)
|
||||
self.button_layout.addWidget(self.cancel_button)
|
||||
self.button_layout.addWidget(self.add_button)
|
||||
|
||||
self.layout.addLayout(self.button_layout)
|
||||
|
||||
def get_device_config(self):
|
||||
"""Get the device configuration from the dialog."""
|
||||
return {
|
||||
"name": self.name_input.text(),
|
||||
"deviceClass": self.device_class_input.text(),
|
||||
"readoutPriority": self.readout_priority_combo.currentText(),
|
||||
"enabled": self.enabled_checkbox.isChecked(),
|
||||
"readOnly": self.readonly_checkbox.isChecked(),
|
||||
"documentation": self.documentation_input.text(),
|
||||
"deviceConfig": {}, # Empty config for now
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
window = DeviceManager()
|
||||
window.show()
|
||||
app.exec_()
|
||||
@@ -1,6 +1,7 @@
|
||||
from typing import Literal
|
||||
|
||||
import qtmonaco
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
@@ -12,11 +13,14 @@ class MonacoWidget(BECWidget, QWidget):
|
||||
A simple Monaco editor widget
|
||||
"""
|
||||
|
||||
text_changed = Signal(str)
|
||||
PLUGIN = True
|
||||
ICON_NAME = "code"
|
||||
USER_ACCESS = [
|
||||
"set_text",
|
||||
"get_text",
|
||||
"insert_text",
|
||||
"delete_line",
|
||||
"set_language",
|
||||
"get_language",
|
||||
"set_theme",
|
||||
@@ -25,6 +29,9 @@ class MonacoWidget(BECWidget, QWidget):
|
||||
"set_cursor",
|
||||
"current_cursor",
|
||||
"set_minimap_enabled",
|
||||
"set_vim_mode_enabled",
|
||||
"set_lsp_header",
|
||||
"get_lsp_header",
|
||||
]
|
||||
|
||||
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
|
||||
@@ -36,6 +43,7 @@ class MonacoWidget(BECWidget, QWidget):
|
||||
self.editor = qtmonaco.Monaco(self)
|
||||
layout.addWidget(self.editor)
|
||||
self.setLayout(layout)
|
||||
self.editor.text_changed.connect(self.text_changed.emit)
|
||||
self.editor.initialized.connect(self.apply_theme)
|
||||
|
||||
def apply_theme(self, theme: str | None = None) -> None:
|
||||
@@ -65,6 +73,26 @@ class MonacoWidget(BECWidget, QWidget):
|
||||
"""
|
||||
return self.editor.get_text()
|
||||
|
||||
def insert_text(self, text: str, line: int | None = None, column: int | None = None) -> None:
|
||||
"""
|
||||
Insert text at the current cursor position or at a specified line and column.
|
||||
|
||||
Args:
|
||||
text (str): The text to insert.
|
||||
line (int, optional): The line number (1-based) to insert the text at. Defaults to None.
|
||||
column (int, optional): The column number (1-based) to insert the text at. Defaults to None.
|
||||
"""
|
||||
self.editor.insert_text(text, line, column)
|
||||
|
||||
def delete_line(self, line: int | None = None) -> None:
|
||||
"""
|
||||
Delete a line in the Monaco editor.
|
||||
|
||||
Args:
|
||||
line (int, optional): The line number (1-based) to delete. If None, the current line will be deleted.
|
||||
"""
|
||||
self.editor.delete_line(line)
|
||||
|
||||
def set_cursor(
|
||||
self,
|
||||
line: int,
|
||||
@@ -154,6 +182,34 @@ class MonacoWidget(BECWidget, QWidget):
|
||||
"""
|
||||
self.editor.clear_highlighted_lines()
|
||||
|
||||
def set_vim_mode_enabled(self, enabled: bool) -> None:
|
||||
"""
|
||||
Enable or disable Vim mode in the Monaco editor.
|
||||
|
||||
Args:
|
||||
enabled (bool): If True, Vim mode will be enabled; otherwise, it will be disabled.
|
||||
"""
|
||||
self.editor.set_vim_mode_enabled(enabled)
|
||||
|
||||
def set_lsp_header(self, header: str) -> None:
|
||||
"""
|
||||
Set the LSP (Language Server Protocol) header for the Monaco editor.
|
||||
The header is used to provide context for language servers but is not displayed in the editor.
|
||||
|
||||
Args:
|
||||
header (str): The LSP header to set.
|
||||
"""
|
||||
self.editor.set_lsp_header(header)
|
||||
|
||||
def get_lsp_header(self) -> str:
|
||||
"""
|
||||
Get the current LSP header set in the Monaco editor.
|
||||
|
||||
Returns:
|
||||
str: The LSP header.
|
||||
"""
|
||||
return self.editor.get_lsp_header()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
qapp = QApplication([])
|
||||
|
||||
@@ -6,11 +6,12 @@ import time
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from louie.saferef import safe_ref
|
||||
from qtpy.QtCore import QUrl, qInstallMessageHandler
|
||||
from qtpy.QtCore import QTimer, QUrl, Signal, qInstallMessageHandler
|
||||
from qtpy.QtWebEngineWidgets import QWebEnginePage, QWebEngineView
|
||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -165,11 +166,16 @@ class WebConsole(BECWidget, QWidget):
|
||||
A simple widget to display a website
|
||||
"""
|
||||
|
||||
_js_callback = Signal(bool)
|
||||
initialized = Signal()
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "terminal"
|
||||
|
||||
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
|
||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
||||
self._startup_cmd = "bec --nogui"
|
||||
self._is_initialized = False
|
||||
_web_console_registry.register(self)
|
||||
self._token = _web_console_registry._token
|
||||
layout = QVBoxLayout()
|
||||
@@ -181,6 +187,48 @@ class WebConsole(BECWidget, QWidget):
|
||||
layout.addWidget(self.browser)
|
||||
self.setLayout(layout)
|
||||
self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}"))
|
||||
self._startup_timer = QTimer()
|
||||
self._startup_timer.setInterval(500)
|
||||
self._startup_timer.timeout.connect(self._check_page_ready)
|
||||
self._startup_timer.start()
|
||||
self._js_callback.connect(self._on_js_callback)
|
||||
|
||||
def _check_page_ready(self):
|
||||
"""
|
||||
Check if the page is ready and stop the timer if it is.
|
||||
"""
|
||||
if self.page.isLoading():
|
||||
return
|
||||
|
||||
self.page.runJavaScript("window.term !== undefined", self._js_callback.emit)
|
||||
|
||||
def _on_js_callback(self, ready: bool):
|
||||
"""
|
||||
Callback for when the JavaScript is ready.
|
||||
"""
|
||||
if not ready:
|
||||
return
|
||||
self._is_initialized = True
|
||||
self._startup_timer.stop()
|
||||
if self._startup_cmd:
|
||||
self.write(self._startup_cmd)
|
||||
self.initialized.emit()
|
||||
|
||||
@SafeProperty(str)
|
||||
def startup_cmd(self):
|
||||
"""
|
||||
Get the startup command for the web console.
|
||||
"""
|
||||
return self._startup_cmd
|
||||
|
||||
@startup_cmd.setter
|
||||
def startup_cmd(self, cmd: str):
|
||||
"""
|
||||
Set the startup command for the web console.
|
||||
"""
|
||||
if not isinstance(cmd, str):
|
||||
raise ValueError("Startup command must be a string.")
|
||||
self._startup_cmd = cmd
|
||||
|
||||
def write(self, data: str, send_return: bool = True):
|
||||
"""
|
||||
@@ -213,10 +261,19 @@ class WebConsole(BECWidget, QWidget):
|
||||
"document.querySelector('textarea.xterm-helper-textarea').dispatchEvent(new KeyboardEvent('keypress', {charCode: 3}))"
|
||||
)
|
||||
|
||||
def set_readonly(self, readonly: bool):
|
||||
"""
|
||||
Set the web console to read-only mode.
|
||||
"""
|
||||
if not isinstance(readonly, bool):
|
||||
raise ValueError("Readonly must be a boolean.")
|
||||
self.setEnabled(not readonly)
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Clean up the registry by removing any instances that are no longer valid.
|
||||
"""
|
||||
self._startup_timer.stop()
|
||||
_web_console_registry.unregister(self)
|
||||
super().cleanup()
|
||||
|
||||
|
||||
@@ -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.32.0"
|
||||
version = "2.35.0"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
@@ -13,17 +13,17 @@ classifiers = [
|
||||
"Topic :: Scientific/Engineering",
|
||||
]
|
||||
dependencies = [
|
||||
"bec_ipython_client>=3.42.4, <=4.0", # needed for jupyter console
|
||||
"bec_lib>=3.44, <=4.0",
|
||||
"bec_ipython_client~=3.52", # needed for jupyter console
|
||||
"bec_lib~=3.52",
|
||||
"bec_qthemes~=0.7, >=0.7",
|
||||
"black~=25.0", # needed for bw-generate-cli
|
||||
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
|
||||
"black~=25.0", # needed for bw-generate-cli
|
||||
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
|
||||
"pydantic~=2.0",
|
||||
"pyqtgraph~=0.13",
|
||||
"PySide6~=6.8.2",
|
||||
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
|
||||
"PySide6==6.9.0",
|
||||
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
|
||||
"qtpy~=2.4",
|
||||
"qtmonaco>=0.2.3",
|
||||
"qtmonaco~=0.5",
|
||||
]
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ dev = [
|
||||
"pytest-xvfb~=3.0",
|
||||
"pytest~=8.0",
|
||||
"pytest-cov~=6.1.1",
|
||||
"watchdog~=6.0",
|
||||
"pre_commit~=4.2",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -88,3 +88,60 @@ def test_web_console_registry_wait_for_server_port_timeout():
|
||||
with mock.patch.object(_web_console_registry, "_server_process") as mock_subprocess:
|
||||
with pytest.raises(TimeoutError):
|
||||
_web_console_registry._wait_for_server_port(timeout=0.1)
|
||||
|
||||
|
||||
def test_web_console_startup_command_execution(console_widget, qtbot):
|
||||
"""Test that the startup command is triggered after successful initialization."""
|
||||
# Set a custom startup command
|
||||
console_widget.startup_cmd = "test startup command"
|
||||
|
||||
assert console_widget.startup_cmd == "test startup command"
|
||||
|
||||
# Generator to simulate JS initialization sequence
|
||||
def js_readiness_sequence():
|
||||
yield False # First call: not ready yet
|
||||
while True:
|
||||
yield True # Any subsequent calls: ready
|
||||
|
||||
readiness_gen = js_readiness_sequence()
|
||||
|
||||
def mock_run_js(script, callback=None):
|
||||
# Check if this is the initialization check call
|
||||
if "window.term !== undefined" in script and callback:
|
||||
ready = next(readiness_gen)
|
||||
callback(ready)
|
||||
else:
|
||||
# For other JavaScript calls (like paste), just call the callback
|
||||
if callback:
|
||||
callback(True)
|
||||
|
||||
with mock.patch.object(
|
||||
console_widget.page, "runJavaScript", side_effect=mock_run_js
|
||||
) as mock_run_js_method:
|
||||
# Reset initialization state and start the timer
|
||||
console_widget._is_initialized = False
|
||||
console_widget._startup_timer.start()
|
||||
|
||||
# Wait for the initialization to complete
|
||||
qtbot.waitUntil(lambda: console_widget._is_initialized, timeout=3000)
|
||||
|
||||
# Verify that the startup command was executed
|
||||
startup_calls = [
|
||||
call
|
||||
for call in mock_run_js_method.call_args_list
|
||||
if "test startup command" in str(call)
|
||||
]
|
||||
assert len(startup_calls) > 0, "Startup command should have been executed"
|
||||
|
||||
# Verify the initialized signal was emitted
|
||||
assert console_widget._is_initialized is True
|
||||
assert not console_widget._startup_timer.isActive()
|
||||
|
||||
|
||||
def test_web_console_set_readonly(console_widget):
|
||||
# Test the set_readonly method
|
||||
console_widget.set_readonly(True)
|
||||
assert not console_widget.isEnabled()
|
||||
|
||||
console_widget.set_readonly(False)
|
||||
assert console_widget.isEnabled()
|
||||
|
||||
Reference in New Issue
Block a user