mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-08-09 19:50:34 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4d4b78846 | ||
|
|
2dc0227d38 | ||
|
|
2d8e1eed4d | ||
|
|
3b579e740f | ||
|
|
b8740c9594 | ||
|
|
d5bf10e216 | ||
|
|
3a165b26ed | ||
|
|
faa200bf5c | ||
|
|
b0fc0d325e | ||
|
|
daa1ba020c | ||
|
|
3d934a8c38 | ||
|
|
c47b246a9f | ||
|
|
bb6c0bb08f | ||
|
|
ce456572d7 | ||
|
|
e22ab7e4c1 | ||
|
|
0cd000dfa1 | ||
|
|
1057db9d76 | ||
|
|
be35e249f9 | ||
|
|
cdd833dfc2 | ||
|
|
3c7834b492 | ||
|
|
acd35a2786 | ||
|
|
108b249f1d | ||
|
|
085f9fa271 | ||
|
|
79931faf55 | ||
|
|
6b3cebe9cb | ||
|
|
5cc82425f0 | ||
|
|
bb1544ecb7 | ||
|
|
8ad0e46d98 | ||
|
|
9d92f8b53a | ||
|
|
c1d5069a48 | ||
|
|
0b1f0b4c26 | ||
|
|
cc825972c2 | ||
|
|
17865a2c33 | ||
|
|
0728811238 | ||
|
|
717d74b19e | ||
|
|
dd32caf6e8 | ||
|
|
603edede9c | ||
|
|
30ef25533a | ||
|
|
73b44cffb2 | ||
|
|
a614d662d6 | ||
|
|
3f1aa80756 | ||
|
|
409c9e5bfa | ||
|
|
19b5c8f724 | ||
|
|
5056ef8946 | ||
|
|
551d38d901 | ||
|
|
999b7a2321 | ||
|
|
5dc373bd8e | ||
|
|
91afc775d5 | ||
|
|
55694ff2b9 | ||
|
|
5b68a51aaa | ||
|
|
f13fa75e25 | ||
|
|
0cf84cd1d8 | ||
|
|
3e77f54034 | ||
|
|
f7616102d8 | ||
|
|
5a497c3598 | ||
|
|
23e3644619 | ||
|
|
a5db2dc340 | ||
|
|
2e8f43fcac | ||
|
|
09bb1121d8 |
@@ -0,0 +1,169 @@
|
|||||||
|
##########################
|
||||||
|
### AI-generated file. ###
|
||||||
|
##########################
|
||||||
|
|
||||||
|
"""Aggregate and merge benchmark JSON files.
|
||||||
|
|
||||||
|
The workflow runs the same benchmark suite on multiple independent runners.
|
||||||
|
This script reads every JSON file produced by those attempts, normalizes the
|
||||||
|
contained benchmark values, and writes a compact mapping JSON where each value is
|
||||||
|
the median across attempts. It can also merge independent hyperfine JSON files
|
||||||
|
from one runner into a single hyperfine-style JSON file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import statistics
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from compare_benchmarks import Benchmark, extract_benchmarks
|
||||||
|
|
||||||
|
|
||||||
|
def collect_benchmarks(paths: list[Path]) -> dict[str, list[Benchmark]]:
|
||||||
|
"""Collect benchmarks from multiple JSON files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
paths (list[Path]): Paths to hyperfine, pytest-benchmark, or compact
|
||||||
|
mapping JSON files.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, list[Benchmark]]: Benchmarks grouped by benchmark name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
collected: dict[str, list[Benchmark]] = {}
|
||||||
|
for path in paths:
|
||||||
|
for name, benchmark in extract_benchmarks(path).items():
|
||||||
|
collected.setdefault(name, []).append(benchmark)
|
||||||
|
return collected
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate(collected: dict[str, list[Benchmark]]) -> dict[str, dict[str, object]]:
|
||||||
|
"""Aggregate grouped benchmarks using the median value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
collected (dict[str, list[Benchmark]]): Benchmarks grouped by benchmark
|
||||||
|
name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, dict[str, object]]: Compact mapping JSON data. Each benchmark
|
||||||
|
contains ``value``, ``unit``, ``metric``, ``attempts``, and
|
||||||
|
``attempt_values``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
aggregated: dict[str, dict[str, object]] = {}
|
||||||
|
for name, benchmarks in sorted(collected.items()):
|
||||||
|
values = [benchmark.value for benchmark in benchmarks]
|
||||||
|
unit = next((benchmark.unit for benchmark in benchmarks if benchmark.unit), "")
|
||||||
|
metric = next((benchmark.metric for benchmark in benchmarks if benchmark.metric), "value")
|
||||||
|
aggregated[name] = {
|
||||||
|
"value": statistics.median(values),
|
||||||
|
"unit": unit,
|
||||||
|
"metric": f"median-of-attempt-{metric}",
|
||||||
|
"attempts": len(values),
|
||||||
|
"attempt_values": values,
|
||||||
|
}
|
||||||
|
return aggregated
|
||||||
|
|
||||||
|
|
||||||
|
def merge_hyperfine_results(paths: list[Path]) -> dict[str, Any]:
|
||||||
|
"""Merge hyperfine result files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
paths (list[Path]): Hyperfine JSON files to merge.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Any]: Hyperfine-style JSON object containing all result rows.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If any file has no hyperfine ``results`` list.
|
||||||
|
"""
|
||||||
|
|
||||||
|
merged: dict[str, Any] = {"results": []}
|
||||||
|
for path in paths:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
results = data.get("results", []) if isinstance(data, dict) else None
|
||||||
|
if not isinstance(results, list):
|
||||||
|
raise ValueError(f"{path} has no hyperfine results list")
|
||||||
|
merged["results"].extend(results)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def main_from_paths(input_dir: Path, output: Path) -> int:
|
||||||
|
"""Aggregate all JSON files in a directory and write the result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_dir (Path): Directory containing benchmark JSON files.
|
||||||
|
output (Path): Path where the aggregate JSON should be written.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Always ``0`` on success.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If no JSON files are found in ``input_dir``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
paths = sorted(input_dir.rglob("*.json"))
|
||||||
|
if not paths:
|
||||||
|
raise ValueError(f"No benchmark JSON files found in {input_dir}")
|
||||||
|
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output.write_text(
|
||||||
|
json.dumps(aggregate(collect_benchmarks(paths)), indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def merge_from_paths(input_dir: Path, output: Path) -> int:
|
||||||
|
"""Merge all hyperfine JSON files in a directory and write the result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_dir (Path): Directory containing hyperfine JSON files.
|
||||||
|
output (Path): Path where the merged JSON should be written.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Always ``0`` on success.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If no JSON files are found in ``input_dir``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
paths = sorted(input_dir.glob("*.json"))
|
||||||
|
if not paths:
|
||||||
|
raise ValueError(f"No hyperfine JSON files found in {input_dir}")
|
||||||
|
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output.write_text(
|
||||||
|
json.dumps(merge_hyperfine_results(paths), indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Run the benchmark aggregation command line interface.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Always ``0`` on success.
|
||||||
|
"""
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
"--mode",
|
||||||
|
choices=("aggregate", "merge-hyperfine"),
|
||||||
|
default="aggregate",
|
||||||
|
help="Operation to perform.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--input-dir", required=True, type=Path)
|
||||||
|
parser.add_argument("--output", required=True, type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.mode == "merge-hyperfine":
|
||||||
|
return merge_from_paths(input_dir=args.input_dir, output=args.output)
|
||||||
|
return main_from_paths(input_dir=args.input_dir, output=args.output)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
##########################
|
||||||
|
### AI-generated file. ###
|
||||||
|
##########################
|
||||||
|
|
||||||
|
"""Compare benchmark JSON files and write a GitHub Actions summary.
|
||||||
|
|
||||||
|
The script supports JSON emitted by hyperfine, JSON emitted by pytest-benchmark,
|
||||||
|
and a compact mapping format generated by ``aggregate_benchmarks.py``. Timing
|
||||||
|
formats prefer median values and fall back to mean values when median values are
|
||||||
|
not present.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Benchmark:
|
||||||
|
"""Normalized benchmark result.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name (str): Stable benchmark name used to match baseline and current results.
|
||||||
|
value (float): Numeric benchmark value used for comparison.
|
||||||
|
unit (str): Display unit for the value, for example ``"s"``.
|
||||||
|
metric (str): Source metric name, for example ``"median"`` or ``"mean"``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
value: float
|
||||||
|
unit: str
|
||||||
|
metric: str = "value"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Comparison:
|
||||||
|
"""Comparison between one baseline benchmark and one current benchmark.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name (str): Benchmark name.
|
||||||
|
baseline (float): Baseline benchmark value.
|
||||||
|
current (float): Current benchmark value.
|
||||||
|
delta_percent (float): Percent change from baseline to current.
|
||||||
|
unit (str): Display unit for both values.
|
||||||
|
metric (str): Current result metric used for comparison.
|
||||||
|
regressed (bool): Whether the change exceeds the configured threshold in
|
||||||
|
the worse direction.
|
||||||
|
improved (bool): Whether the change exceeds the configured threshold in
|
||||||
|
the better direction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
baseline: float
|
||||||
|
current: float
|
||||||
|
delta_percent: float
|
||||||
|
unit: str
|
||||||
|
metric: str
|
||||||
|
regressed: bool
|
||||||
|
improved: bool
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json(path: Path) -> Any:
|
||||||
|
"""Read JSON data from a file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path (Path): Path to the JSON file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Any: Parsed JSON value.
|
||||||
|
"""
|
||||||
|
|
||||||
|
with path.open("r", encoding="utf-8") as stream:
|
||||||
|
return json.load(stream)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_float(value: Any) -> float | None:
|
||||||
|
"""Convert a value to a finite float.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value (Any): Value to convert.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float | None: Converted finite float, or ``None`` if conversion fails.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if math.isfinite(result):
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_hyperfine(data: dict[str, Any]) -> dict[str, Benchmark]:
|
||||||
|
"""Extract normalized benchmarks from hyperfine JSON.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data (dict[str, Any]): Parsed hyperfine JSON object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Benchmark]: Benchmarks keyed by command name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
benchmarks: dict[str, Benchmark] = {}
|
||||||
|
for result in data.get("results", []):
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
continue
|
||||||
|
name = str(result.get("command") or result.get("name") or "").strip()
|
||||||
|
metric = "median"
|
||||||
|
value = _as_float(result.get(metric))
|
||||||
|
if value is None:
|
||||||
|
metric = "mean"
|
||||||
|
value = _as_float(result.get(metric))
|
||||||
|
if name and value is not None:
|
||||||
|
benchmarks[name] = Benchmark(name=name, value=value, unit="s", metric=metric)
|
||||||
|
return benchmarks
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_pytest_benchmark(data: dict[str, Any]) -> dict[str, Benchmark]:
|
||||||
|
"""Extract normalized benchmarks from pytest-benchmark JSON.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data (dict[str, Any]): Parsed pytest-benchmark JSON object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Benchmark]: Benchmarks keyed by full benchmark name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
benchmarks: dict[str, Benchmark] = {}
|
||||||
|
for benchmark in data.get("benchmarks", []):
|
||||||
|
if not isinstance(benchmark, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = str(benchmark.get("fullname") or benchmark.get("name") or "").strip()
|
||||||
|
stats = benchmark.get("stats", {})
|
||||||
|
value = None
|
||||||
|
metric = "median"
|
||||||
|
if isinstance(stats, dict):
|
||||||
|
value = _as_float(stats.get(metric))
|
||||||
|
if value is None:
|
||||||
|
metric = "mean"
|
||||||
|
value = _as_float(stats.get(metric))
|
||||||
|
if name and value is not None:
|
||||||
|
benchmarks[name] = Benchmark(name=name, value=value, unit="s", metric=metric)
|
||||||
|
return benchmarks
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_simple_mapping(data: dict[str, Any]) -> dict[str, Benchmark]:
|
||||||
|
"""Extract normalized benchmarks from a compact mapping JSON object.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data (dict[str, Any]): Parsed mapping where each benchmark is either a
|
||||||
|
raw number or an object containing ``value``, ``unit``, and ``metric``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Benchmark]: Benchmarks keyed by mapping key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
benchmarks: dict[str, Benchmark] = {}
|
||||||
|
|
||||||
|
for name, raw_value in data.items():
|
||||||
|
if name in {"version", "context", "commit", "timestamp"}:
|
||||||
|
continue
|
||||||
|
|
||||||
|
value = _as_float(raw_value)
|
||||||
|
unit = ""
|
||||||
|
metric = "value"
|
||||||
|
if value is None and isinstance(raw_value, dict):
|
||||||
|
value = _as_float(raw_value.get("value"))
|
||||||
|
unit = str(raw_value.get("unit") or "")
|
||||||
|
metric = str(raw_value.get("metric") or "value")
|
||||||
|
|
||||||
|
if value is not None:
|
||||||
|
benchmarks[str(name)] = Benchmark(name=str(name), value=value, unit=unit, metric=metric)
|
||||||
|
|
||||||
|
return benchmarks
|
||||||
|
|
||||||
|
|
||||||
|
def extract_benchmarks(path: Path) -> dict[str, Benchmark]:
|
||||||
|
"""Extract normalized benchmarks from a supported JSON file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path (Path): Path to a hyperfine, pytest-benchmark, or compact mapping
|
||||||
|
JSON file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Benchmark]: Normalized benchmarks keyed by name.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the JSON root is not an object or no supported benchmark
|
||||||
|
entries can be extracted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = _read_json(path)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError(f"{path} must contain a JSON object")
|
||||||
|
|
||||||
|
extractors = (_extract_hyperfine, _extract_pytest_benchmark, _extract_simple_mapping)
|
||||||
|
for extractor in extractors:
|
||||||
|
benchmarks = extractor(data)
|
||||||
|
if benchmarks:
|
||||||
|
return benchmarks
|
||||||
|
|
||||||
|
raise ValueError(f"No supported benchmark entries found in {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def compare_benchmarks(
|
||||||
|
baseline: dict[str, Benchmark],
|
||||||
|
current: dict[str, Benchmark],
|
||||||
|
threshold_percent: float,
|
||||||
|
higher_is_better: bool,
|
||||||
|
) -> tuple[list[Comparison], list[str], list[str]]:
|
||||||
|
"""Compare baseline benchmarks with current benchmarks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
baseline (dict[str, Benchmark]): Baseline benchmarks keyed by name.
|
||||||
|
current (dict[str, Benchmark]): Current benchmarks keyed by name.
|
||||||
|
threshold_percent (float): Regression threshold in percent.
|
||||||
|
higher_is_better (bool): If ``True``, lower current values are treated as
|
||||||
|
regressions. If ``False``, higher current values are treated as
|
||||||
|
regressions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[list[Comparison], list[str], list[str]]: Comparisons for common
|
||||||
|
benchmark names, names missing from current results, and names newly
|
||||||
|
present in current results.
|
||||||
|
"""
|
||||||
|
|
||||||
|
comparisons: list[Comparison] = []
|
||||||
|
missing_in_current: list[str] = []
|
||||||
|
new_in_current: list[str] = []
|
||||||
|
|
||||||
|
for name, baseline_benchmark in sorted(baseline.items()):
|
||||||
|
current_benchmark = current.get(name)
|
||||||
|
if current_benchmark is None:
|
||||||
|
missing_in_current.append(name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if baseline_benchmark.value == 0:
|
||||||
|
delta_percent = 0.0
|
||||||
|
else:
|
||||||
|
delta_percent = (
|
||||||
|
(current_benchmark.value - baseline_benchmark.value)
|
||||||
|
/ abs(baseline_benchmark.value)
|
||||||
|
* 100
|
||||||
|
)
|
||||||
|
|
||||||
|
if higher_is_better:
|
||||||
|
regressed = delta_percent <= -threshold_percent
|
||||||
|
improved = delta_percent >= threshold_percent
|
||||||
|
else:
|
||||||
|
regressed = delta_percent >= threshold_percent
|
||||||
|
improved = delta_percent <= -threshold_percent
|
||||||
|
|
||||||
|
comparisons.append(
|
||||||
|
Comparison(
|
||||||
|
name=name,
|
||||||
|
baseline=baseline_benchmark.value,
|
||||||
|
current=current_benchmark.value,
|
||||||
|
delta_percent=delta_percent,
|
||||||
|
unit=current_benchmark.unit or baseline_benchmark.unit,
|
||||||
|
metric=current_benchmark.metric,
|
||||||
|
regressed=regressed,
|
||||||
|
improved=improved,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for name in sorted(set(current) - set(baseline)):
|
||||||
|
new_in_current.append(name)
|
||||||
|
|
||||||
|
return comparisons, missing_in_current, new_in_current
|
||||||
|
|
||||||
|
|
||||||
|
def _format_value(value: float, unit: str) -> str:
|
||||||
|
"""Format a benchmark value for Markdown output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value (float): Numeric benchmark value.
|
||||||
|
unit (str): Display unit.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Formatted value with optional unit suffix.
|
||||||
|
"""
|
||||||
|
|
||||||
|
suffix = f" {unit}" if unit else ""
|
||||||
|
return f"{value:.6g}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_status(comparison: Comparison) -> str:
|
||||||
|
"""Format a comparison status for Markdown output."""
|
||||||
|
|
||||||
|
if comparison.regressed:
|
||||||
|
return ":red_circle: regressed"
|
||||||
|
if comparison.improved:
|
||||||
|
return ":green_circle: improved"
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def write_summary(
|
||||||
|
path: Path,
|
||||||
|
comparisons: list[Comparison],
|
||||||
|
missing_in_current: list[str],
|
||||||
|
new_in_current: list[str],
|
||||||
|
threshold_percent: float,
|
||||||
|
higher_is_better: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Write a Markdown benchmark comparison summary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path (Path): Path where the summary should be written.
|
||||||
|
comparisons (list[Comparison]): Comparison rows for matching benchmarks.
|
||||||
|
missing_in_current (list[str]): Baseline benchmark names missing from the
|
||||||
|
current result.
|
||||||
|
new_in_current (list[str]): Current benchmark names not present in the
|
||||||
|
baseline result.
|
||||||
|
threshold_percent (float): Regression threshold in percent.
|
||||||
|
higher_is_better (bool): Whether higher benchmark values are considered
|
||||||
|
better.
|
||||||
|
"""
|
||||||
|
|
||||||
|
regressions = [comparison for comparison in comparisons if comparison.regressed]
|
||||||
|
improvements = [comparison for comparison in comparisons if comparison.improved]
|
||||||
|
direction = "higher is better" if higher_is_better else "lower is better"
|
||||||
|
sorted_comparisons = sorted(comparisons, key=lambda comparison: comparison.name)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"<!-- bw-benchmark-comment -->",
|
||||||
|
"## Benchmark comparison",
|
||||||
|
"",
|
||||||
|
f"Threshold: {threshold_percent:g}% ({direction}).",
|
||||||
|
f"Result: {len(regressions)} regression(s), {len(improvements)} improvement(s) beyond threshold.",
|
||||||
|
]
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if regressions:
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f"{len(regressions)} benchmark(s) regressed beyond the configured threshold.",
|
||||||
|
"",
|
||||||
|
"| Benchmark | Baseline | Current | Change |",
|
||||||
|
"| --- | ---: | ---: | ---: |",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for comparison in regressions:
|
||||||
|
lines.append(
|
||||||
|
"| "
|
||||||
|
f"{comparison.name} | "
|
||||||
|
f"{_format_value(comparison.baseline, comparison.unit)} | "
|
||||||
|
f"{_format_value(comparison.current, comparison.unit)} | "
|
||||||
|
f"{comparison.delta_percent:+.2f}% |"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append("No benchmark regression exceeded the configured threshold.")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if improvements:
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f"{len(improvements)} benchmark(s) improved beyond the configured threshold.",
|
||||||
|
"",
|
||||||
|
"| Benchmark | Baseline | Current | Change |",
|
||||||
|
"| --- | ---: | ---: | ---: |",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for comparison in improvements:
|
||||||
|
lines.append(
|
||||||
|
"| "
|
||||||
|
f"{comparison.name} | "
|
||||||
|
f"{_format_value(comparison.baseline, comparison.unit)} | "
|
||||||
|
f"{_format_value(comparison.current, comparison.unit)} | "
|
||||||
|
f"{comparison.delta_percent:+.2f}% |"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append("No benchmark improvement exceeded the configured threshold.")
|
||||||
|
|
||||||
|
if sorted_comparisons:
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"<details>",
|
||||||
|
"<summary>All benchmark results</summary>",
|
||||||
|
"",
|
||||||
|
"| Benchmark | Baseline | Current | Change | Status |",
|
||||||
|
"| --- | ---: | ---: | ---: | --- |",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for comparison in sorted_comparisons:
|
||||||
|
lines.append(
|
||||||
|
"| "
|
||||||
|
f"{comparison.name} | "
|
||||||
|
f"{_format_value(comparison.baseline, comparison.unit)} | "
|
||||||
|
f"{_format_value(comparison.current, comparison.unit)} | "
|
||||||
|
f"{comparison.delta_percent:+.2f}% | "
|
||||||
|
f"{_format_status(comparison)} |"
|
||||||
|
)
|
||||||
|
lines.extend(["", "</details>"])
|
||||||
|
|
||||||
|
if missing_in_current:
|
||||||
|
lines.extend(["", "Missing benchmarks in the current run:"])
|
||||||
|
lines.extend(f"- `{name}`" for name in missing_in_current)
|
||||||
|
|
||||||
|
if new_in_current:
|
||||||
|
lines.extend(["", "New benchmarks in the current run:"])
|
||||||
|
lines.extend(f"- `{name}`" for name in new_in_current)
|
||||||
|
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Run the benchmark comparison command line interface.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: ``1`` when a regression exceeds the threshold, otherwise ``0``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--baseline", required=True, type=Path)
|
||||||
|
parser.add_argument("--current", required=True, type=Path)
|
||||||
|
parser.add_argument("--summary", required=True, type=Path)
|
||||||
|
parser.add_argument("--threshold-percent", required=True, type=float)
|
||||||
|
parser.add_argument("--higher-is-better", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
baseline = extract_benchmarks(args.baseline)
|
||||||
|
current = extract_benchmarks(args.current)
|
||||||
|
comparisons, missing_in_current, new_in_current = compare_benchmarks(
|
||||||
|
baseline=baseline,
|
||||||
|
current=current,
|
||||||
|
threshold_percent=args.threshold_percent,
|
||||||
|
higher_is_better=args.higher_is_better,
|
||||||
|
)
|
||||||
|
|
||||||
|
write_summary(
|
||||||
|
path=args.summary,
|
||||||
|
comparisons=comparisons,
|
||||||
|
missing_in_current=missing_in_current,
|
||||||
|
new_in_current=new_in_current,
|
||||||
|
threshold_percent=args.threshold_percent,
|
||||||
|
higher_is_better=args.higher_is_better,
|
||||||
|
)
|
||||||
|
|
||||||
|
return 1 if any(comparison.regressed for comparison in comparisons) else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
##########################
|
||||||
|
### AI-generated file. ###
|
||||||
|
##########################
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
mkdir -p benchmark-results
|
||||||
|
benchmark_json="${BENCHMARK_JSON:-benchmark-results/current.json}"
|
||||||
|
benchmark_root="$(dirname "$benchmark_json")"
|
||||||
|
hyperfine_benchmark_dir="${BENCHMARK_HYPERFINE_DIR:-tests/benchmarks/hyperfine}"
|
||||||
|
pytest_benchmark_dirs="${BENCHMARK_PYTEST_DIRS:-${BENCHMARK_PYTEST_DIR:-}}"
|
||||||
|
benchmark_work_dir="$benchmark_root/raw-results"
|
||||||
|
hyperfine_json_dir="$benchmark_work_dir/hyperfine"
|
||||||
|
pytest_json="$benchmark_work_dir/pytest.json"
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
benchmark_scripts=()
|
||||||
|
benchmark_scripts=("$hyperfine_benchmark_dir"/benchmark_*.sh)
|
||||||
|
shopt -u nullglob
|
||||||
|
|
||||||
|
pytest_dirs=()
|
||||||
|
for pytest_benchmark_dir in $pytest_benchmark_dirs; do
|
||||||
|
if [ -d "$pytest_benchmark_dir" ]; then
|
||||||
|
pytest_dirs+=("$pytest_benchmark_dir")
|
||||||
|
else
|
||||||
|
echo "Pytest benchmark directory not found: $pytest_benchmark_dir" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "${#benchmark_scripts[@]}" -eq 0 ] && [ "${#pytest_dirs[@]}" -eq 0 ]; then
|
||||||
|
echo "No benchmark scripts or pytest benchmarks found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Benchmark Python: $(command -v python)"
|
||||||
|
python -c 'import sys; print(sys.version)'
|
||||||
|
|
||||||
|
rm -rf "$benchmark_work_dir"
|
||||||
|
mkdir -p "$hyperfine_json_dir"
|
||||||
|
|
||||||
|
if [ "${#benchmark_scripts[@]}" -gt 0 ]; then
|
||||||
|
for benchmark_script in "${benchmark_scripts[@]}"; do
|
||||||
|
title="$(sed -n 's/^# BENCHMARK_TITLE:[[:space:]]*//p' "$benchmark_script" | head -n 1)"
|
||||||
|
if [ -z "$title" ]; then
|
||||||
|
title="$(basename "$benchmark_script" .sh)"
|
||||||
|
fi
|
||||||
|
benchmark_name="$(basename "$benchmark_script" .sh)"
|
||||||
|
benchmark_result_json="$hyperfine_json_dir/$benchmark_name.json"
|
||||||
|
echo "Preflight benchmark script: $benchmark_script"
|
||||||
|
bash "$benchmark_script"
|
||||||
|
|
||||||
|
hyperfine \
|
||||||
|
--show-output \
|
||||||
|
--warmup 1 \
|
||||||
|
--runs 5 \
|
||||||
|
--command-name "$title" \
|
||||||
|
--export-json "$benchmark_result_json" \
|
||||||
|
"bash $(printf "%q" "$benchmark_script")"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${#pytest_dirs[@]}" -gt 0 ]; then
|
||||||
|
pytest \
|
||||||
|
-q "${pytest_dirs[@]}" \
|
||||||
|
--benchmark-only \
|
||||||
|
--benchmark-json "$pytest_json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
python .github/scripts/aggregate_benchmarks.py \
|
||||||
|
--input-dir "$benchmark_work_dir" \
|
||||||
|
--output "$benchmark_json"
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
##########################
|
||||||
|
### AI-generated file. ###
|
||||||
|
##########################
|
||||||
|
|
||||||
|
"""Run a command with BEC e2e services available."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import bec_lib
|
||||||
|
from bec_ipython_client import BECIPythonClient
|
||||||
|
from bec_lib.redis_connector import RedisConnector
|
||||||
|
from bec_lib.service_config import ServiceConfig, ServiceConfigModel
|
||||||
|
from redis import Redis
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_redis(host: str, port: int) -> None:
|
||||||
|
client = Redis(host=host, port=port)
|
||||||
|
deadline = time.monotonic() + 10
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
if client.ping():
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
time.sleep(0.1)
|
||||||
|
raise RuntimeError(f"Redis did not start on {host}:{port}")
|
||||||
|
|
||||||
|
|
||||||
|
def _start_redis(files_path: Path, host: str, port: int) -> subprocess.Popen:
|
||||||
|
redis_server = shutil.which("redis-server")
|
||||||
|
if redis_server is None:
|
||||||
|
raise RuntimeError("redis-server executable not found")
|
||||||
|
|
||||||
|
return subprocess.Popen(
|
||||||
|
[
|
||||||
|
redis_server,
|
||||||
|
"--bind",
|
||||||
|
host,
|
||||||
|
"--port",
|
||||||
|
str(port),
|
||||||
|
"--save",
|
||||||
|
"",
|
||||||
|
"--appendonly",
|
||||||
|
"no",
|
||||||
|
"--dir",
|
||||||
|
str(files_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_configs(files_path: Path, host: str, port: int) -> Path:
|
||||||
|
test_config = files_path / "test_config.yaml"
|
||||||
|
services_config = files_path / "services_config.yaml"
|
||||||
|
|
||||||
|
bec_lib_path = Path(bec_lib.__file__).resolve().parent
|
||||||
|
shutil.copyfile(bec_lib_path / "tests" / "test_config.yaml", test_config)
|
||||||
|
|
||||||
|
service_config = ServiceConfigModel(
|
||||||
|
redis={"host": host, "port": port}, file_writer={"base_path": str(files_path)}
|
||||||
|
)
|
||||||
|
services_config.write_text(service_config.model_dump_json(indent=4), encoding="utf-8")
|
||||||
|
return services_config
|
||||||
|
|
||||||
|
|
||||||
|
def _load_demo_config(services_config: Path) -> None:
|
||||||
|
bec = BECIPythonClient(ServiceConfig(services_config), RedisConnector, forced=True)
|
||||||
|
bec.start()
|
||||||
|
try:
|
||||||
|
bec.config.load_demo_config()
|
||||||
|
finally:
|
||||||
|
bec.shutdown()
|
||||||
|
bec._client._reset_singleton()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("command", nargs=argparse.REMAINDER)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command[:1] == ["--"]:
|
||||||
|
args.command = args.command[1:]
|
||||||
|
if not args.command:
|
||||||
|
raise ValueError("No command provided")
|
||||||
|
|
||||||
|
host = "127.0.0.1"
|
||||||
|
port = 6379
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="bec-benchmark-") as tmp:
|
||||||
|
files_path = Path(tmp)
|
||||||
|
services_config = _write_configs(files_path, host, port)
|
||||||
|
redis_process = _start_redis(files_path, host, port)
|
||||||
|
processes = None
|
||||||
|
service_handler = None
|
||||||
|
try:
|
||||||
|
_wait_for_redis(host, port)
|
||||||
|
|
||||||
|
from bec_server.bec_server_utils.service_handler import ServiceHandler
|
||||||
|
|
||||||
|
service_handler = ServiceHandler(
|
||||||
|
bec_path=files_path, config_path=services_config, interface="subprocess"
|
||||||
|
)
|
||||||
|
processes = service_handler.start()
|
||||||
|
_load_demo_config(services_config)
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
return subprocess.run(args.command, env=env, check=False).returncode
|
||||||
|
finally:
|
||||||
|
if service_handler is not None and processes is not None:
|
||||||
|
service_handler.stop(processes)
|
||||||
|
redis_process.terminate()
|
||||||
|
try:
|
||||||
|
redis_process.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
redis_process.kill()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
name: BW Benchmarks
|
||||||
|
|
||||||
|
on: [ workflow_call ]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
BENCHMARK_JSON: benchmark-results/current.json
|
||||||
|
BENCHMARK_BASELINE_JSON: gh-pages-benchmark-data/benchmarks/latest.json
|
||||||
|
BENCHMARK_SUMMARY: benchmark-results/summary.md
|
||||||
|
BENCHMARK_COMMAND: "bash .github/scripts/run_benchmarks.sh"
|
||||||
|
BENCHMARK_THRESHOLD_PERCENT: 20
|
||||||
|
BENCHMARK_HIGHER_IS_BETTER: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
benchmark_attempt:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
continue-on-error: true
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash -el {0}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
attempt: [ 1, 2, 3 ]
|
||||||
|
|
||||||
|
env:
|
||||||
|
BENCHMARK_JSON: benchmark-results/current-${{ matrix.attempt }}.json
|
||||||
|
BEC_CORE_BRANCH: main
|
||||||
|
OPHYD_DEVICES_BRANCH: main
|
||||||
|
PLUGIN_REPO_BRANCH: main
|
||||||
|
BENCHMARK_PYTEST_DIRS: tests/unit_tests/benchmarks
|
||||||
|
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||||
|
QT_QPA_PLATFORM: "offscreen"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout BEC Widgets
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: bec-project/bec_widgets
|
||||||
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
|
||||||
|
- name: Set up Conda
|
||||||
|
uses: conda-incubator/setup-miniconda@v3
|
||||||
|
with:
|
||||||
|
auto-update-conda: true
|
||||||
|
auto-activate-base: true
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
|
||||||
|
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
|
||||||
|
sudo apt-get -y install ttyd hyperfine redis-server
|
||||||
|
|
||||||
|
- name: Install full e2e environment
|
||||||
|
run: |
|
||||||
|
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
|
||||||
|
git clone --branch "$BEC_CORE_BRANCH" https://github.com/bec-project/bec.git
|
||||||
|
echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
|
||||||
|
git clone --branch "$OPHYD_DEVICES_BRANCH" https://github.com/bec-project/ophyd_devices.git
|
||||||
|
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||||
|
echo -e "\033[35;1m Using branch $PLUGIN_REPO_BRANCH of bec_testing_plugin \033[0;m";
|
||||||
|
git clone --branch "$PLUGIN_REPO_BRANCH" https://github.com/bec-project/bec_testing_plugin.git
|
||||||
|
cd ./bec
|
||||||
|
conda create -q -n test-environment python=3.11
|
||||||
|
conda activate test-environment
|
||||||
|
source ./bin/install_bec_dev.sh -t
|
||||||
|
cd ../
|
||||||
|
python -m pip install -e ./ophyd_devices -e .[dev,pyside6] -e ./bec_testing_plugin pytest-benchmark
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$BENCHMARK_JSON")"
|
||||||
|
python .github/scripts/run_with_bec_servers.py -- bash -lc "$BENCHMARK_COMMAND"
|
||||||
|
test -s "$BENCHMARK_JSON"
|
||||||
|
|
||||||
|
- name: Upload benchmark artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: bw-benchmark-json-${{ matrix.attempt }}
|
||||||
|
path: ${{ env.BENCHMARK_JSON }}
|
||||||
|
|
||||||
|
benchmark:
|
||||||
|
needs: [ benchmark_attempt ]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout BEC Widgets
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: bec-project/bec_widgets
|
||||||
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
|
||||||
|
- name: Download benchmark attempts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
pattern: bw-benchmark-json-*
|
||||||
|
path: benchmark-results/attempts
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Aggregate benchmark attempts
|
||||||
|
run: |
|
||||||
|
python .github/scripts/aggregate_benchmarks.py \
|
||||||
|
--input-dir benchmark-results/attempts \
|
||||||
|
--output "$BENCHMARK_JSON"
|
||||||
|
|
||||||
|
- name: Upload aggregate benchmark artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: bw-benchmark-json
|
||||||
|
path: ${{ env.BENCHMARK_JSON }}
|
||||||
|
|
||||||
|
- name: Fetch gh-pages benchmark data
|
||||||
|
run: |
|
||||||
|
if git ls-remote --exit-code --heads origin gh-pages; then
|
||||||
|
git clone --depth=1 --branch gh-pages "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" gh-pages-benchmark-data
|
||||||
|
else
|
||||||
|
mkdir -p gh-pages-benchmark-data
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Compare with latest gh-pages benchmark
|
||||||
|
id: compare
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
if [ ! -s "$BENCHMARK_BASELINE_JSON" ]; then
|
||||||
|
mkdir -p "$(dirname "$BENCHMARK_SUMMARY")"
|
||||||
|
{
|
||||||
|
echo "<!-- bw-benchmark-comment -->"
|
||||||
|
echo "## Benchmark comparison"
|
||||||
|
echo
|
||||||
|
echo "No benchmark baseline was found on gh-pages."
|
||||||
|
} > "$BENCHMARK_SUMMARY"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
args=(
|
||||||
|
--baseline "$BENCHMARK_BASELINE_JSON"
|
||||||
|
--current "$BENCHMARK_JSON"
|
||||||
|
--summary "$BENCHMARK_SUMMARY"
|
||||||
|
--threshold-percent "$BENCHMARK_THRESHOLD_PERCENT"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "$BENCHMARK_HIGHER_IS_BETTER" = "true" ]; then
|
||||||
|
args+=(--higher-is-better)
|
||||||
|
fi
|
||||||
|
|
||||||
|
set +e
|
||||||
|
python .github/scripts/compare_benchmarks.py "${args[@]}"
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ ! -s "$BENCHMARK_SUMMARY" ]; then
|
||||||
|
mkdir -p "$(dirname "$BENCHMARK_SUMMARY")"
|
||||||
|
{
|
||||||
|
echo "<!-- bw-benchmark-comment -->"
|
||||||
|
echo "## Benchmark comparison"
|
||||||
|
echo
|
||||||
|
echo "Benchmark comparison failed before writing a summary."
|
||||||
|
} > "$BENCHMARK_SUMMARY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$status"
|
||||||
|
|
||||||
|
- name: Find existing benchmark PR comment
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
id: fc
|
||||||
|
uses: peter-evans/find-comment@v3
|
||||||
|
with:
|
||||||
|
issue-number: ${{ github.event.pull_request.number }}
|
||||||
|
comment-author: github-actions[bot]
|
||||||
|
body-includes: "<!-- bw-benchmark-comment -->"
|
||||||
|
|
||||||
|
- name: Create or update benchmark PR comment
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
uses: peter-evans/create-or-update-comment@v5
|
||||||
|
with:
|
||||||
|
issue-number: ${{ github.event.pull_request.number }}
|
||||||
|
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||||
|
body-path: ${{ env.BENCHMARK_SUMMARY }}
|
||||||
|
edit-mode: replace
|
||||||
|
|
||||||
|
- name: Fail on benchmark regression
|
||||||
|
if: github.event_name == 'pull_request' && steps.compare.outcome == 'failure'
|
||||||
|
run: exit 1
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: [ benchmark ]
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout BEC Widgets
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: bec-project/bec_widgets
|
||||||
|
ref: ${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Download aggregate benchmark artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: bw-benchmark-json
|
||||||
|
path: benchmark-results
|
||||||
|
|
||||||
|
- name: Verify aggregate benchmark artifact
|
||||||
|
run: test -s "$BENCHMARK_JSON"
|
||||||
|
|
||||||
|
- name: Prepare gh-pages for publishing
|
||||||
|
run: |
|
||||||
|
# Clean up any existing worktree/directory
|
||||||
|
if [ -d gh-pages-benchmark-data ]; then
|
||||||
|
git worktree remove gh-pages-benchmark-data --force || rm -rf gh-pages-benchmark-data
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git ls-remote --exit-code --heads origin gh-pages; then
|
||||||
|
git fetch --depth=1 origin gh-pages
|
||||||
|
git worktree add gh-pages-benchmark-data FETCH_HEAD
|
||||||
|
else
|
||||||
|
git worktree add --detach gh-pages-benchmark-data
|
||||||
|
git -C gh-pages-benchmark-data checkout --orphan gh-pages
|
||||||
|
git -C gh-pages-benchmark-data rm -rf .
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Publish benchmark data to gh-pages
|
||||||
|
working-directory: gh-pages-benchmark-data
|
||||||
|
run: |
|
||||||
|
mkdir -p benchmarks/history
|
||||||
|
cp "../$BENCHMARK_JSON" benchmarks/latest.json
|
||||||
|
cp "../$BENCHMARK_JSON" "benchmarks/history/${GITHUB_SHA}.json"
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add benchmarks/latest.json "benchmarks/history/${GITHUB_SHA}.json"
|
||||||
|
git commit -m "Update BW benchmark data for ${GITHUB_SHA}" || exit 0
|
||||||
|
git push origin HEAD:gh-pages
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
name: Full CI
|
name: Full CI
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
BEC_WIDGETS_BRANCH:
|
BEC_WIDGETS_BRANCH:
|
||||||
description: 'Branch of BEC Widgets to install'
|
description: "Branch of BEC Widgets to install"
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
BEC_CORE_BRANCH:
|
BEC_CORE_BRANCH:
|
||||||
description: 'Branch of BEC Core to install'
|
description: "Branch of BEC Core to install"
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
OPHYD_DEVICES_BRANCH:
|
OPHYD_DEVICES_BRANCH:
|
||||||
description: 'Branch of Ophyd Devices to install'
|
description: "Branch of Ophyd Devices to install"
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ concurrency:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check_pr_status:
|
check_pr_status:
|
||||||
@@ -33,6 +34,15 @@ jobs:
|
|||||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||||
uses: ./.github/workflows/formatter.yml
|
uses: ./.github/workflows/formatter.yml
|
||||||
|
|
||||||
|
benchmark:
|
||||||
|
needs: [check_pr_status]
|
||||||
|
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
uses: ./.github/workflows/benchmark.yml
|
||||||
|
|
||||||
unit-test:
|
unit-test:
|
||||||
needs: [check_pr_status, formatter]
|
needs: [check_pr_status, formatter]
|
||||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||||
@@ -69,9 +79,9 @@ jobs:
|
|||||||
uses: ./.github/workflows/child_repos.yml
|
uses: ./.github/workflows/child_repos.yml
|
||||||
with:
|
with:
|
||||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main'}}
|
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main'}}
|
||||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
|
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
|
||||||
|
|
||||||
plugin_repos:
|
plugin_repos:
|
||||||
needs: [check_pr_status, formatter]
|
needs: [check_pr_status, formatter]
|
||||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||||
@@ -81,4 +91,4 @@ jobs:
|
|||||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
|
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
|
||||||
|
|
||||||
secrets:
|
secrets:
|
||||||
GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }}
|
GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }}
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
name: Run Pytest with different Python versions
|
name: Run Pytest with different Python versions
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
pr_number:
|
pr_number:
|
||||||
description: 'Pull request number'
|
description: "Pull request number"
|
||||||
required: false
|
required: false
|
||||||
type: number
|
type: number
|
||||||
BEC_CORE_BRANCH:
|
BEC_CORE_BRANCH:
|
||||||
description: 'Branch of BEC Core to install'
|
description: "Branch of BEC Core to install"
|
||||||
required: false
|
required: false
|
||||||
default: 'main'
|
default: "main"
|
||||||
type: string
|
type: string
|
||||||
OPHYD_DEVICES_BRANCH:
|
OPHYD_DEVICES_BRANCH:
|
||||||
description: 'Branch of Ophyd Devices to install'
|
description: "Branch of Ophyd Devices to install"
|
||||||
required: false
|
required: false
|
||||||
default: 'main'
|
default: "main"
|
||||||
type: string
|
type: string
|
||||||
BEC_WIDGETS_BRANCH:
|
BEC_WIDGETS_BRANCH:
|
||||||
description: 'Branch of BEC Widgets to install'
|
description: "Branch of BEC Widgets to install"
|
||||||
required: false
|
required: false
|
||||||
default: 'main'
|
default: "main"
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -30,15 +30,14 @@ jobs:
|
|||||||
python-version: ["3.11", "3.12", "3.13"]
|
python-version: ["3.11", "3.12", "3.13"]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets
|
BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets
|
||||||
BEC_CORE_BRANCH: main # Set the branch you want for bec
|
BEC_CORE_BRANCH: main # Set the branch you want for bec
|
||||||
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
|
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
|
||||||
PROJECT_PATH: ${{ github.repository }}
|
PROJECT_PATH: ${{ github.repository }}
|
||||||
QTWEBENGINE_DISABLE_SANDBOX: 1
|
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||||
QT_QPA_PLATFORM: "offscreen"
|
QT_QPA_PLATFORM: "offscreen"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
- name: Checkout BEC Widgets
|
- name: Checkout BEC Widgets
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -56,4 +55,4 @@ jobs:
|
|||||||
- name: Run Pytest
|
- name: Run Pytest
|
||||||
run: |
|
run: |
|
||||||
pip install pytest pytest-random-order
|
pip install pytest pytest-random-order
|
||||||
pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
|
pytest -v --junitxml=report.xml --random-order --ignore=tests/unit_tests/benchmarks ./tests/unit_tests
|
||||||
|
|||||||
@@ -1,32 +1,30 @@
|
|||||||
name: Run Pytest with Coverage
|
name: Run Pytest with Coverage
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
pr_number:
|
pr_number:
|
||||||
description: 'Pull request number'
|
description: "Pull request number"
|
||||||
required: false
|
required: false
|
||||||
type: number
|
type: number
|
||||||
BEC_CORE_BRANCH:
|
BEC_CORE_BRANCH:
|
||||||
description: 'Branch of BEC Core to install'
|
description: "Branch of BEC Core to install"
|
||||||
required: false
|
required: false
|
||||||
default: 'main'
|
default: "main"
|
||||||
type: string
|
type: string
|
||||||
OPHYD_DEVICES_BRANCH:
|
OPHYD_DEVICES_BRANCH:
|
||||||
description: 'Branch of Ophyd Devices to install'
|
description: "Branch of Ophyd Devices to install"
|
||||||
required: false
|
required: false
|
||||||
default: 'main'
|
default: "main"
|
||||||
type: string
|
type: string
|
||||||
BEC_WIDGETS_BRANCH:
|
BEC_WIDGETS_BRANCH:
|
||||||
description: 'Branch of BEC Widgets to install'
|
description: "Branch of BEC Widgets to install"
|
||||||
required: false
|
required: false
|
||||||
default: 'main'
|
default: "main"
|
||||||
type: string
|
type: string
|
||||||
secrets:
|
secrets:
|
||||||
CODECOV_TOKEN:
|
CODECOV_TOKEN:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
@@ -55,7 +53,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Pytest with Coverage
|
- name: Run Pytest with Coverage
|
||||||
id: coverage
|
id: coverage
|
||||||
run: pytest --random-order --cov=bec_widgets --cov-config=pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail tests/unit_tests/
|
run: pytest --random-order --cov=bec_widgets --cov-config=pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail --ignore=tests/unit_tests/benchmarks tests/unit_tests/
|
||||||
|
|
||||||
- name: Upload test artifacts
|
- name: Upload test artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -69,4 +67,4 @@ jobs:
|
|||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
slug: bec-project/bec_widgets
|
slug: bec-project/bec_widgets
|
||||||
|
|||||||
+231
@@ -1,6 +1,237 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
|
||||||
|
## v3.12.0 (2026-05-20)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **scan-control**: Filter out private scans from allowed scans
|
||||||
|
([`2dc0227`](https://github.com/bec-project/bec_widgets/commit/2dc0227d38f0e217e252a5e5751bafd60363a5a4))
|
||||||
|
|
||||||
|
- **scan-control**: Hide hidden scan arguments
|
||||||
|
([`2d8e1ee`](https://github.com/bec-project/bec_widgets/commit/2d8e1eed4d6503c42a38c8de910ddaa54132405d))
|
||||||
|
|
||||||
|
- **scan-control**: Reject unsupported scan input types
|
||||||
|
([`3b579e7`](https://github.com/bec-project/bec_widgets/commit/3b579e740f36c60c3635681a9b2c35b518498f58))
|
||||||
|
|
||||||
|
- **scan-control**: Skip duplicate visible scan kwargs
|
||||||
|
([`b8740c9`](https://github.com/bec-project/bec_widgets/commit/b8740c95941d36102f07a51d74a50e6f262a6646))
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add support for new scan signatures including units
|
||||||
|
([`d5bf10e`](https://github.com/bec-project/bec_widgets/commit/d5bf10e21682ae8270078c7858a036bafbabf10e))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.11.1 (2026-05-18)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **scan progressbar**: Fix device subscription cleanup
|
||||||
|
([`faa200b`](https://github.com/bec-project/bec_widgets/commit/faa200bf5c3cf0c5bebb9858700106899f583695))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.11.0 (2026-05-15)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Remove device/signal line edit and abstraction layer for combobox/lineEdit
|
||||||
|
([`bb6c0bb`](https://github.com/bec-project/bec_widgets/commit/bb6c0bb08fc9802bec0d6b9994a76a5bcf2a3a81))
|
||||||
|
|
||||||
|
- **scan_control**: Scangroupbox enforce correct device combobox type in correct order
|
||||||
|
([`c47b246`](https://github.com/bec-project/bec_widgets/commit/c47b246a9fd5c9aff2512c2744b8ff19c87e6e03))
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **device_input**: Comboboxes can have line edit like autocomplete
|
||||||
|
([`3d934a8`](https://github.com/bec-project/bec_widgets/commit/3d934a8c3825b17319c3cb99750b96042e0bc230))
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
- **device_input**: Consolidation of device/signal combobox logic; docsrtings added
|
||||||
|
([`daa1ba0`](https://github.com/bec-project/bec_widgets/commit/daa1ba020ce6d05800186d2467a496c1024e8aa5))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.10.0 (2026-05-13)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Fix link to doc page
|
||||||
|
([`0cd000d`](https://github.com/bec-project/bec_widgets/commit/0cd000dfa1bb6f1b4d286e5aab30299361f436f6))
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Bl plugin menu in BECDockArea
|
||||||
|
([`e22ab7e`](https://github.com/bec-project/bec_widgets/commit/e22ab7e4c10552e22aaaa9dbc30d098fbfa9c49c))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.9.1 (2026-05-12)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Logpanel fixture overwriting xread
|
||||||
|
([`3c7834b`](https://github.com/bec-project/bec_widgets/commit/3c7834b492a5d2da13689f58b20caf38dda9ac1d))
|
||||||
|
|
||||||
|
- **scan_control**: Restore scan parameters from history are fetched on demand with button
|
||||||
|
([`acd35a2`](https://github.com/bec-project/bec_widgets/commit/acd35a278660ce4962167af6237b5d12007f0774))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.9.0 (2026-05-12)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Test bw-generate-cli
|
||||||
|
([`085f9fa`](https://github.com/bec-project/bec_widgets/commit/085f9fa271a0a8e339bff83f235011ac4a9d29ea))
|
||||||
|
|
||||||
|
- **dock_area**: Icon fetching for toolbar import optimised
|
||||||
|
([`79931fa`](https://github.com/bec-project/bec_widgets/commit/79931faf554fd0978c54d6562aa1b5fc4ab823b2))
|
||||||
|
|
||||||
|
- **jupyter_console_widget**: Widget_handler API fix
|
||||||
|
([`6b3cebe`](https://github.com/bec-project/bec_widgets/commit/6b3cebe9cbdb5c02ae2aa14b0f624a51c9c2ca4c))
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Move to lazy widget import
|
||||||
|
([`5cc8242`](https://github.com/bec-project/bec_widgets/commit/5cc82425f07d76e881ae59a121a3af77f227bfee))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Fix available scans endpoint operation
|
||||||
|
([`bb1544e`](https://github.com/bec-project/bec_widgets/commit/bb1544ecb70612267e2b03ba041c6f656789d63c))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.8.1 (2026-05-11)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **web_links**: Update documentation links in BECWebLinksMixin
|
||||||
|
([`9d92f8b`](https://github.com/bec-project/bec_widgets/commit/9d92f8b53a6ffe57a9dffad797580228023bf6e1))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.8.0 (2026-05-01)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **dock_area**: Change to show_dialo=False for CLI profile baseline restore
|
||||||
|
([`0b1f0b4`](https://github.com/bec-project/bec_widgets/commit/0b1f0b4c262ff31469b7114b9f00bf0a7b85e8f2))
|
||||||
|
|
||||||
|
- **dock_area**: Cli call load_profile has restore_baseline kwarg
|
||||||
|
([`cc82597`](https://github.com/bec-project/bec_widgets/commit/cc825972c202cd9ded32f8b2d1ce5f822c2ebdba))
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **dock_area**: Add CLI restore current profile from baseline with optional confirmation dialog
|
||||||
|
([`17865a2`](https://github.com/bec-project/bec_widgets/commit/17865a2c338a4a1f944659dde4ec05c25a8dd963))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.7.3 (2026-05-01)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **dock_area**: Profile names changed, default->baseline, user->runtime
|
||||||
|
([`dd32caf`](https://github.com/bec-project/bec_widgets/commit/dd32caf6e815fd1922b6aae84d00decad9dbf869))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- **dock_area**: Remove low-value tests
|
||||||
|
([`717d74b`](https://github.com/bec-project/bec_widgets/commit/717d74b19e8c6960209190c47ba32732ffaa0094))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.7.2 (2026-04-29)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **dock-area**: Avoid switching profile when saving new profile
|
||||||
|
([`73b44cf`](https://github.com/bec-project/bec_widgets/commit/73b44cffb219347cacb609f3b93068eda6701b42))
|
||||||
|
|
||||||
|
- **workspace-actions**: Use try/finally and restore previous blocked state in refresh_profiles
|
||||||
|
([`30ef255`](https://github.com/bec-project/bec_widgets/commit/30ef25533af9df5a9bd9e69808dc49fdf22f4318))
|
||||||
|
|
||||||
|
Agent-Logs-Url:
|
||||||
|
https://github.com/bec-project/bec_widgets/sessions/004cb4bc-5015-485e-a803-1e63876b7024
|
||||||
|
|
||||||
|
Co-authored-by: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com>
|
||||||
|
|
||||||
|
### Build System
|
||||||
|
|
||||||
|
- Add pytest-benchmark dependency
|
||||||
|
([`551d38d`](https://github.com/bec-project/bec_widgets/commit/551d38d90111361e64bfcf10a1409be71dd298bc))
|
||||||
|
|
||||||
|
### Chores
|
||||||
|
|
||||||
|
- Update header comments in script files to indicate AI generation
|
||||||
|
([`3f1aa80`](https://github.com/bec-project/bec_widgets/commit/3f1aa80756368454c3631cb6cf9d29db28af177a))
|
||||||
|
|
||||||
|
### Continuous Integration
|
||||||
|
|
||||||
|
- Add benchmark workflow
|
||||||
|
([`999b7a2`](https://github.com/bec-project/bec_widgets/commit/999b7a2321f2f222c04b056a2db4280f66de9c48))
|
||||||
|
|
||||||
|
- Fix benchmark upload
|
||||||
|
([`19b5c8f`](https://github.com/bec-project/bec_widgets/commit/19b5c8f724dbdb7421957c290f9213bf072392df))
|
||||||
|
|
||||||
|
- Increase threshold to 20 percent
|
||||||
|
([`409c9e5`](https://github.com/bec-project/bec_widgets/commit/409c9e5bfacdfc003f1bc8c9944f01798bd3818e))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Fix assertions after updating ophyd devices templates
|
||||||
|
([`a614d66`](https://github.com/bec-project/bec_widgets/commit/a614d662d6da716a49afb4ed3a3903f108210386))
|
||||||
|
|
||||||
|
Co-authored-by: Copilot <copilot@github.com>
|
||||||
|
|
||||||
|
- Remove references to "scan_motors" in tests
|
||||||
|
([`5056ef8`](https://github.com/bec-project/bec_widgets/commit/5056ef8946d03a20e802709d3fe81c84c195fe41))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.7.1 (2026-04-21)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **heatmap**: Fix access to status from metadata
|
||||||
|
([`55694ff`](https://github.com/bec-project/bec_widgets/commit/55694ff2b96581e03c63c8b8e068e2db79bcf780))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Fix exit status and status access in tests
|
||||||
|
([`91afc77`](https://github.com/bec-project/bec_widgets/commit/91afc775d59b4ba31bed3585847a67c301acf9b0))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.7.0 (2026-04-21)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Move companion app to applications
|
||||||
|
([`0cf84cd`](https://github.com/bec-project/bec_widgets/commit/0cf84cd1d839ac4a39ffb5fb9ba57d432e04348a))
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
- Cleanup of imports
|
||||||
|
([`3e77f54`](https://github.com/bec-project/bec_widgets/commit/3e77f540345f56b9f184a332fcdd50d4d4c8c621))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.6.0 (2026-04-21)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Change resize mode to interactive
|
||||||
|
([`a5db2dc`](https://github.com/bec-project/bec_widgets/commit/a5db2dc340f3386e68b300fd4528a44f87cbbf97))
|
||||||
|
|
||||||
|
- Small usability changes
|
||||||
|
([`5a497c3`](https://github.com/bec-project/bec_widgets/commit/5a497c3598c2d8f27916d91d53c646d5d6d3a4a7))
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add button/slot to pause/unpause logs
|
||||||
|
([`23e3644`](https://github.com/bec-project/bec_widgets/commit/23e3644619de958bcfdb8a0b2ee1f7c2ce05b235))
|
||||||
|
|
||||||
|
- Add logpanel to menu
|
||||||
|
([`2e8f43f`](https://github.com/bec-project/bec_widgets/commit/2e8f43fcac581cd1c227308198565d142a1bf276))
|
||||||
|
|
||||||
|
- Migrate logpanel to table model/view
|
||||||
|
([`09bb112`](https://github.com/bec-project/bec_widgets/commit/09bb1121d83bac1f6e4827daa476fbe7cd5b3a80))
|
||||||
|
|
||||||
|
|
||||||
## v3.5.1 (2026-04-20)
|
## v3.5.1 (2026-04-20)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -192,8 +192,7 @@ Positioner boxes and tweak controls handle precise moves, homing, and calibratio
|
|||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of
|
The documentation can be found [here](https://bec.readthedocs.io/).
|
||||||
the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+12
-18
@@ -1,19 +1,13 @@
|
|||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import bec_widgets.widgets.containers.qt_ads as QtAds
|
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
|
||||||
|
|
||||||
if sys.platform.startswith("linux"):
|
|
||||||
qt_platform = os.environ.get("QT_QPA_PLATFORM", "")
|
|
||||||
if qt_platform != "offscreen":
|
|
||||||
os.environ["QT_QPA_PLATFORM"] = "xcb"
|
|
||||||
|
|
||||||
# Default QtAds configuration
|
|
||||||
QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True)
|
|
||||||
QtAds.CDockManager.setConfigFlag(
|
|
||||||
QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = ["BECWidget", "SafeSlot", "SafeProperty"]
|
__all__ = ["BECWidget", "SafeSlot", "SafeProperty"]
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name):
|
||||||
|
if name == "BECWidget":
|
||||||
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
|
|
||||||
|
return BECWidget
|
||||||
|
if name in {"SafeSlot", "SafeProperty"}:
|
||||||
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
|
|
||||||
|
return {"SafeSlot": SafeSlot, "SafeProperty": SafeProperty}[name]
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import bec_widgets.widgets.containers.qt_ads as QtAds
|
||||||
|
|
||||||
|
if sys.platform.startswith("linux"):
|
||||||
|
qt_platform = os.environ.get("QT_QPA_PLATFORM", "")
|
||||||
|
if qt_platform != "offscreen":
|
||||||
|
os.environ["QT_QPA_PLATFORM"] = "xcb"
|
||||||
|
|
||||||
|
# Default QtAds configuration
|
||||||
|
QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True)
|
||||||
|
QtAds.CDockManager.setConfigFlag(
|
||||||
|
QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True
|
||||||
|
)
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ from qtpy.QtWidgets import QApplication
|
|||||||
|
|
||||||
import bec_widgets
|
import bec_widgets
|
||||||
from bec_widgets.applications.launch_window import LaunchWindow
|
from bec_widgets.applications.launch_window import LaunchWindow
|
||||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
|
||||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||||
|
from bec_widgets.utils.rpc_register import RPCRegister
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
|
|
||||||
@@ -20,13 +20,13 @@ from qtpy.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
import bec_widgets
|
import bec_widgets
|
||||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
|
||||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
||||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||||
from bec_widgets.utils.error_popups import SafeSlot
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
from bec_widgets.utils.name_utils import pascal_to_snake
|
from bec_widgets.utils.name_utils import pascal_to_snake
|
||||||
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
|
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
|
||||||
from bec_widgets.utils.round_frame import RoundedFrame
|
from bec_widgets.utils.round_frame import RoundedFrame
|
||||||
|
from bec_widgets.utils.rpc_register import RPCRegister
|
||||||
from bec_widgets.utils.screen_utils import apply_window_geometry, centered_geometry_for_app
|
from bec_widgets.utils.screen_utils import apply_window_geometry, centered_geometry_for_app
|
||||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||||
from bec_widgets.utils.ui_loader import UILoader
|
from bec_widgets.utils.ui_loader import UILoader
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
from bec_widgets.cli.rpc import rpc_base
|
||||||
|
|||||||
+56
-48
@@ -340,10 +340,10 @@ class BECDockArea(RPCBase):
|
|||||||
Save the current workspace profile.
|
Save the current workspace profile.
|
||||||
|
|
||||||
On first save of a given name:
|
On first save of a given name:
|
||||||
- writes a default copy to states/default/<name>.ini with tag=default and created_at
|
- writes a baseline copy to profiles/baseline/<name>.ini with created_at
|
||||||
- writes a user copy to states/user/<name>.ini with tag=user and created_at
|
- writes a runtime copy to profiles/runtime/<name>.ini with created_at
|
||||||
On subsequent saves of user-owned profiles:
|
On subsequent saves:
|
||||||
- updates both the default and user copies so restore uses the latest snapshot.
|
- updates both the baseline and runtime copies so restore uses the latest snapshot.
|
||||||
Read-only bundled profiles cannot be overwritten.
|
Read-only bundled profiles cannot be overwritten.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -358,15 +358,31 @@ class BECDockArea(RPCBase):
|
|||||||
|
|
||||||
@rpc_timeout(None)
|
@rpc_timeout(None)
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def load_profile(self, name: "str | None" = None):
|
def load_profile(self, name: "str | None" = None, restore_baseline: "bool" = False):
|
||||||
"""
|
"""
|
||||||
Load a workspace profile.
|
Load a workspace profile.
|
||||||
|
|
||||||
Before switching, persist the current profile to the user copy.
|
Before switching, persist the current profile to the runtime copy.
|
||||||
Prefer loading the user copy; fall back to the default copy.
|
Prefer loading the runtime copy; fall back to the baseline copy. When
|
||||||
|
``restore_baseline`` is True, first overwrite the runtime copy with the
|
||||||
|
baseline profile and then load it.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str | None): The name of the profile to load. If None, prompts the user.
|
name (str | None): The name of the profile to load. If None, prompts the user.
|
||||||
|
restore_baseline (bool): If True, restore the runtime copy from the
|
||||||
|
baseline before loading. Defaults to False.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@rpc_timeout(None)
|
||||||
|
@rpc_call
|
||||||
|
def restore_baseline_profile(self, name: "str | None" = None, show_dialog: "bool" = False):
|
||||||
|
"""
|
||||||
|
Overwrite the runtime copy of *name* with the baseline.
|
||||||
|
If *name* is None, target the currently active profile.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name (str | None): The name of the profile to restore. If None, uses the current profile.
|
||||||
|
show_dialog (bool): If True, ask for confirmation before restoring.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
@@ -1115,30 +1131,6 @@ class DeviceInitializationProgressBar(RPCBase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class DeviceInputBase(RPCBase):
|
|
||||||
"""Mixin base class for device input widgets."""
|
|
||||||
|
|
||||||
_IMPORT_MODULE = "bec_widgets.widgets.control.device_input.base_classes.device_input_base"
|
|
||||||
|
|
||||||
@rpc_call
|
|
||||||
def remove(self):
|
|
||||||
"""
|
|
||||||
Cleanup the BECConnector
|
|
||||||
"""
|
|
||||||
|
|
||||||
@rpc_call
|
|
||||||
def attach(self):
|
|
||||||
"""
|
|
||||||
None
|
|
||||||
"""
|
|
||||||
|
|
||||||
@rpc_call
|
|
||||||
def detach(self):
|
|
||||||
"""
|
|
||||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class DeviceManagerView(RPCBase):
|
class DeviceManagerView(RPCBase):
|
||||||
"""A view for users to manage devices within the application."""
|
"""A view for users to manage devices within the application."""
|
||||||
|
|
||||||
@@ -1348,10 +1340,10 @@ class DockAreaView(RPCBase):
|
|||||||
Save the current workspace profile.
|
Save the current workspace profile.
|
||||||
|
|
||||||
On first save of a given name:
|
On first save of a given name:
|
||||||
- writes a default copy to states/default/<name>.ini with tag=default and created_at
|
- writes a baseline copy to profiles/baseline/<name>.ini with created_at
|
||||||
- writes a user copy to states/user/<name>.ini with tag=user and created_at
|
- writes a runtime copy to profiles/runtime/<name>.ini with created_at
|
||||||
On subsequent saves of user-owned profiles:
|
On subsequent saves:
|
||||||
- updates both the default and user copies so restore uses the latest snapshot.
|
- updates both the baseline and runtime copies so restore uses the latest snapshot.
|
||||||
Read-only bundled profiles cannot be overwritten.
|
Read-only bundled profiles cannot be overwritten.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -1366,15 +1358,31 @@ class DockAreaView(RPCBase):
|
|||||||
|
|
||||||
@rpc_timeout(None)
|
@rpc_timeout(None)
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def load_profile(self, name: "str | None" = None):
|
def load_profile(self, name: "str | None" = None, restore_baseline: "bool" = False):
|
||||||
"""
|
"""
|
||||||
Load a workspace profile.
|
Load a workspace profile.
|
||||||
|
|
||||||
Before switching, persist the current profile to the user copy.
|
Before switching, persist the current profile to the runtime copy.
|
||||||
Prefer loading the user copy; fall back to the default copy.
|
Prefer loading the runtime copy; fall back to the baseline copy. When
|
||||||
|
``restore_baseline`` is True, first overwrite the runtime copy with the
|
||||||
|
baseline profile and then load it.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str | None): The name of the profile to load. If None, prompts the user.
|
name (str | None): The name of the profile to load. If None, prompts the user.
|
||||||
|
restore_baseline (bool): If True, restore the runtime copy from the
|
||||||
|
baseline before loading. Defaults to False.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@rpc_timeout(None)
|
||||||
|
@rpc_call
|
||||||
|
def restore_baseline_profile(self, name: "str | None" = None, show_dialog: "bool" = False):
|
||||||
|
"""
|
||||||
|
Overwrite the runtime copy of *name* with the baseline.
|
||||||
|
If *name* is None, target the currently active profile.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name (str | None): The name of the profile to restore. If None, uses the current profile.
|
||||||
|
show_dialog (bool): If True, ask for confirmation before restoring.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
@@ -3190,26 +3198,26 @@ class LaunchWindow(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class LogPanel(RPCBase):
|
class LogPanel(RPCBase):
|
||||||
"""Displays a log panel"""
|
"""Live display of the BEC logs in a table view."""
|
||||||
|
|
||||||
_IMPORT_MODULE = "bec_widgets.widgets.utility.logpanel.logpanel"
|
_IMPORT_MODULE = "bec_widgets.widgets.utility.logpanel.logpanel"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_plain_text(self, text: str) -> None:
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
Set the plain text of the widget.
|
Cleanup the BECConnector
|
||||||
|
|
||||||
Args:
|
|
||||||
text (str): The text to set.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_html_text(self, text: str) -> None:
|
def attach(self):
|
||||||
|
"""
|
||||||
|
None
|
||||||
"""
|
"""
|
||||||
Set the HTML text of the widget.
|
|
||||||
|
|
||||||
Args:
|
@rpc_call
|
||||||
text (str): The text to set.
|
def detach(self):
|
||||||
|
"""
|
||||||
|
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# This file was automatically generated by generate_cli.py
|
||||||
|
# type: ignore
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# pylint: skip-file
|
||||||
|
|
||||||
|
designer_plugins = {
|
||||||
|
"AbortButton": ("bec_widgets.widgets.control.buttons.button_abort.button_abort", "AbortButton"),
|
||||||
|
"BECColorMapWidget": (
|
||||||
|
"bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget",
|
||||||
|
"BECColorMapWidget",
|
||||||
|
),
|
||||||
|
"BECMainWindow": ("bec_widgets.widgets.containers.main_window.main_window", "BECMainWindow"),
|
||||||
|
"BECProgressBar": (
|
||||||
|
"bec_widgets.widgets.progress.bec_progressbar.bec_progressbar",
|
||||||
|
"BECProgressBar",
|
||||||
|
),
|
||||||
|
"BECQueue": ("bec_widgets.widgets.services.bec_queue.bec_queue", "BECQueue"),
|
||||||
|
"BECShell": ("bec_widgets.widgets.editors.bec_console.bec_console", "BECShell"),
|
||||||
|
"BECSpinBox": ("bec_widgets.widgets.utility.spinbox.decimal_spinbox", "BECSpinBox"),
|
||||||
|
"BECStatusBox": ("bec_widgets.widgets.services.bec_status_box.bec_status_box", "BECStatusBox"),
|
||||||
|
"BecConsole": ("bec_widgets.widgets.editors.bec_console.bec_console", "BecConsole"),
|
||||||
|
"ColorButton": ("bec_widgets.widgets.utility.visual.color_button.color_button", "ColorButton"),
|
||||||
|
"ColorButtonNative": (
|
||||||
|
"bec_widgets.widgets.utility.visual.color_button_native.color_button_native",
|
||||||
|
"ColorButtonNative",
|
||||||
|
),
|
||||||
|
"ColormapSelector": (
|
||||||
|
"bec_widgets.widgets.utility.visual.colormap_selector.colormap_selector",
|
||||||
|
"ColormapSelector",
|
||||||
|
),
|
||||||
|
"DapComboBox": ("bec_widgets.widgets.dap.dap_combo_box.dap_combo_box", "DapComboBox"),
|
||||||
|
"DarkModeButton": (
|
||||||
|
"bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button",
|
||||||
|
"DarkModeButton",
|
||||||
|
),
|
||||||
|
"DeviceBrowser": (
|
||||||
|
"bec_widgets.widgets.services.device_browser.device_browser",
|
||||||
|
"DeviceBrowser",
|
||||||
|
),
|
||||||
|
"DeviceComboBox": (
|
||||||
|
"bec_widgets.widgets.control.device_input.device_combobox.device_combobox",
|
||||||
|
"DeviceComboBox",
|
||||||
|
),
|
||||||
|
"Heatmap": ("bec_widgets.widgets.plots.heatmap.heatmap", "Heatmap"),
|
||||||
|
"IDEExplorer": ("bec_widgets.widgets.utility.ide_explorer.ide_explorer", "IDEExplorer"),
|
||||||
|
"Image": ("bec_widgets.widgets.plots.image.image", "Image"),
|
||||||
|
"LMFitDialog": ("bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog", "LMFitDialog"),
|
||||||
|
"LogPanel": ("bec_widgets.widgets.utility.logpanel.logpanel", "LogPanel"),
|
||||||
|
"Minesweeper": ("bec_widgets.widgets.games.minesweeper", "Minesweeper"),
|
||||||
|
"MonacoWidget": ("bec_widgets.widgets.editors.monaco.monaco_widget", "MonacoWidget"),
|
||||||
|
"MotorMap": ("bec_widgets.widgets.plots.motor_map.motor_map", "MotorMap"),
|
||||||
|
"MultiWaveform": ("bec_widgets.widgets.plots.multi_waveform.multi_waveform", "MultiWaveform"),
|
||||||
|
"PdfViewerWidget": ("bec_widgets.widgets.utility.pdf_viewer.pdf_viewer", "PdfViewerWidget"),
|
||||||
|
"PositionIndicator": (
|
||||||
|
"bec_widgets.widgets.control.device_control.position_indicator.position_indicator",
|
||||||
|
"PositionIndicator",
|
||||||
|
),
|
||||||
|
"PositionerBox": (
|
||||||
|
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box",
|
||||||
|
"PositionerBox",
|
||||||
|
),
|
||||||
|
"PositionerBox2D": (
|
||||||
|
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box_2d.positioner_box_2d",
|
||||||
|
"PositionerBox2D",
|
||||||
|
),
|
||||||
|
"PositionerControlLine": (
|
||||||
|
"bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line",
|
||||||
|
"PositionerControlLine",
|
||||||
|
),
|
||||||
|
"PositionerGroup": (
|
||||||
|
"bec_widgets.widgets.control.device_control.positioner_group.positioner_group",
|
||||||
|
"PositionerGroup",
|
||||||
|
),
|
||||||
|
"ResetButton": ("bec_widgets.widgets.control.buttons.button_reset.button_reset", "ResetButton"),
|
||||||
|
"ResumeButton": (
|
||||||
|
"bec_widgets.widgets.control.buttons.button_resume.button_resume",
|
||||||
|
"ResumeButton",
|
||||||
|
),
|
||||||
|
"RingProgressBar": (
|
||||||
|
"bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar",
|
||||||
|
"RingProgressBar",
|
||||||
|
),
|
||||||
|
"SBBMonitor": ("bec_widgets.widgets.editors.sbb_monitor.sbb_monitor", "SBBMonitor"),
|
||||||
|
"ScanControl": ("bec_widgets.widgets.control.scan_control.scan_control", "ScanControl"),
|
||||||
|
"ScanMetadata": ("bec_widgets.widgets.editors.scan_metadata.scan_metadata", "ScanMetadata"),
|
||||||
|
"ScanProgressBar": (
|
||||||
|
"bec_widgets.widgets.progress.scan_progressbar.scan_progressbar",
|
||||||
|
"ScanProgressBar",
|
||||||
|
),
|
||||||
|
"ScatterWaveform": (
|
||||||
|
"bec_widgets.widgets.plots.scatter_waveform.scatter_waveform",
|
||||||
|
"ScatterWaveform",
|
||||||
|
),
|
||||||
|
"SignalComboBox": (
|
||||||
|
"bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox",
|
||||||
|
"SignalComboBox",
|
||||||
|
),
|
||||||
|
"SignalLabel": ("bec_widgets.widgets.utility.signal_label.signal_label", "SignalLabel"),
|
||||||
|
"SpinnerWidget": ("bec_widgets.widgets.utility.spinner.spinner", "SpinnerWidget"),
|
||||||
|
"StopButton": ("bec_widgets.widgets.control.buttons.stop_button.stop_button", "StopButton"),
|
||||||
|
"TextBox": ("bec_widgets.widgets.editors.text_box.text_box", "TextBox"),
|
||||||
|
"ToggleSwitch": ("bec_widgets.widgets.utility.toggle.toggle", "ToggleSwitch"),
|
||||||
|
"Waveform": ("bec_widgets.widgets.plots.waveform.waveform", "Waveform"),
|
||||||
|
"WebsiteWidget": ("bec_widgets.widgets.editors.website.website", "WebsiteWidget"),
|
||||||
|
"WidgetFinderComboBox": (
|
||||||
|
"bec_widgets.widgets.utility.widget_finder.widget_finder",
|
||||||
|
"WidgetFinderComboBox",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
widget_icons = {
|
||||||
|
"AbortButton": "cancel",
|
||||||
|
"BECColorMapWidget": "palette",
|
||||||
|
"BECMainWindow": "widgets",
|
||||||
|
"BECProgressBar": "page_control",
|
||||||
|
"BECQueue": "edit_note",
|
||||||
|
"BECShell": "hub",
|
||||||
|
"BECSpinBox": "123",
|
||||||
|
"BECStatusBox": "widgets",
|
||||||
|
"BecConsole": "terminal",
|
||||||
|
"ColorButton": "colors",
|
||||||
|
"ColorButtonNative": "colors",
|
||||||
|
"ColormapSelector": "palette",
|
||||||
|
"DapComboBox": "data_exploration",
|
||||||
|
"DarkModeButton": "dark_mode",
|
||||||
|
"DeviceBrowser": "lists",
|
||||||
|
"DeviceComboBox": "list_alt",
|
||||||
|
"Heatmap": "dataset",
|
||||||
|
"IDEExplorer": "widgets",
|
||||||
|
"Image": "image",
|
||||||
|
"LMFitDialog": "monitoring",
|
||||||
|
"LogPanel": "browse_activity",
|
||||||
|
"Minesweeper": "videogame_asset",
|
||||||
|
"MonacoWidget": "code",
|
||||||
|
"MotorMap": "my_location",
|
||||||
|
"MultiWaveform": "ssid_chart",
|
||||||
|
"PdfViewerWidget": "picture_as_pdf",
|
||||||
|
"PositionIndicator": "horizontal_distribute",
|
||||||
|
"PositionerBox": "switch_right",
|
||||||
|
"PositionerBox2D": "switch_right",
|
||||||
|
"PositionerControlLine": "switch_left",
|
||||||
|
"PositionerGroup": "grid_view",
|
||||||
|
"ResetButton": "restart_alt",
|
||||||
|
"ResumeButton": "resume",
|
||||||
|
"RingProgressBar": "track_changes",
|
||||||
|
"SBBMonitor": "train",
|
||||||
|
"ScanControl": "tune",
|
||||||
|
"ScanMetadata": "list_alt",
|
||||||
|
"ScanProgressBar": "timelapse",
|
||||||
|
"ScatterWaveform": "scatter_plot",
|
||||||
|
"SignalComboBox": "list_alt",
|
||||||
|
"SignalLabel": "scoreboard",
|
||||||
|
"SpinnerWidget": "progress_activity",
|
||||||
|
"StopButton": "dangerous",
|
||||||
|
"TextBox": "chat",
|
||||||
|
"ToggleSwitch": "toggle_on",
|
||||||
|
"Waveform": "show_chart",
|
||||||
|
"WebsiteWidget": "travel_explore",
|
||||||
|
"WidgetFinderComboBox": "frame_inspect",
|
||||||
|
}
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from bec_widgets.cli.client_utils import IGNORE_WIDGETS
|
|
||||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
|
||||||
from bec_widgets.utils.plugin_utils import get_custom_classes
|
|
||||||
|
|
||||||
|
|
||||||
class RPCWidgetHandler:
|
|
||||||
"""Handler class for creating widgets from RPC messages."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._widget_classes = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def widget_classes(self) -> dict[str, type[BECWidget]]:
|
|
||||||
"""
|
|
||||||
Get the available widget classes.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: The available widget classes.
|
|
||||||
"""
|
|
||||||
if self._widget_classes is None:
|
|
||||||
self.update_available_widgets()
|
|
||||||
return self._widget_classes # type: ignore
|
|
||||||
|
|
||||||
def update_available_widgets(self):
|
|
||||||
"""
|
|
||||||
Update the available widgets.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
None
|
|
||||||
"""
|
|
||||||
self._widget_classes = (
|
|
||||||
get_custom_classes("bec_widgets", packages=("widgets", "applications"))
|
|
||||||
+ get_all_plugin_widgets()
|
|
||||||
).as_dict(IGNORE_WIDGETS)
|
|
||||||
|
|
||||||
def create_widget(self, widget_type, **kwargs) -> BECWidget:
|
|
||||||
"""
|
|
||||||
Create a widget from an RPC message.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
widget_type(str): The type of the widget.
|
|
||||||
name (str): The name of the widget.
|
|
||||||
**kwargs: The keyword arguments for the widget.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
widget(BECWidget): The created widget.
|
|
||||||
"""
|
|
||||||
widget_class = self.widget_classes.get(widget_type) # type: ignore
|
|
||||||
if widget_class:
|
|
||||||
return widget_class(**kwargs)
|
|
||||||
raise ValueError(f"Unknown widget type: {widget_type}")
|
|
||||||
|
|
||||||
|
|
||||||
widget_handler = RPCWidgetHandler()
|
|
||||||
@@ -25,8 +25,8 @@ from qtpy.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
|
||||||
from bec_widgets.utils.colors import apply_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
|
from bec_widgets.utils.rpc_widget_handler import widget_handler
|
||||||
from bec_widgets.utils.widget_io import WidgetHierarchy as wh
|
from bec_widgets.utils.widget_io import WidgetHierarchy as wh
|
||||||
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
|
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
|
||||||
|
|
||||||
@@ -206,7 +206,6 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
|||||||
|
|
||||||
def _populate_registry_widgets(self):
|
def _populate_registry_widgets(self):
|
||||||
try:
|
try:
|
||||||
widget_handler.update_available_widgets()
|
|
||||||
items = sorted(widget_handler.widget_classes.keys())
|
items = sorted(widget_handler.widget_classes.keys())
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"Failed to load registered widgets: {exc}")
|
print(f"Failed to load registered widgets: {exc}")
|
||||||
@@ -335,20 +334,13 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
|||||||
|
|
||||||
If kwargs does not contain `object_name`, it will default to the provided shortcut.
|
If kwargs does not contain `object_name`, it will default to the provided shortcut.
|
||||||
"""
|
"""
|
||||||
# Ensure registry is loaded
|
|
||||||
widget_handler.update_available_widgets()
|
|
||||||
cls = widget_handler.widget_classes.get(widget_type)
|
|
||||||
if cls is None:
|
|
||||||
raise ValueError(f"Unknown registered widget type: {widget_type}")
|
|
||||||
|
|
||||||
if kwargs is None:
|
if kwargs is None:
|
||||||
kwargs = {"object_name": shortcut}
|
kwargs = {"object_name": shortcut}
|
||||||
else:
|
else:
|
||||||
kwargs = dict(kwargs)
|
kwargs = dict(kwargs)
|
||||||
kwargs.setdefault("object_name", shortcut)
|
kwargs.setdefault("object_name", shortcut)
|
||||||
|
|
||||||
# Instantiate and add
|
widget = widget_handler.create_widget(widget_type, **kwargs)
|
||||||
widget = cls(**kwargs)
|
|
||||||
if not isinstance(widget, QWidget):
|
if not isinstance(widget, QWidget):
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
f"Instantiated object for type '{widget_type}' is not a QWidget: {type(widget)}"
|
f"Instantiated object for type '{widget_type}' is not a QWidget: {type(widget)}"
|
||||||
|
|||||||
@@ -1,13 +1 @@
|
|||||||
from qtpy.QtWebEngineWidgets import QWebEngineView
|
|
||||||
|
|
||||||
from .bec_connector import BECConnector, ConnectionConfig
|
|
||||||
from .bec_dispatcher import BECDispatcher
|
|
||||||
from .bec_table import BECTable
|
|
||||||
from .colors import Colors
|
|
||||||
from .container_utils import WidgetContainerUtils
|
|
||||||
from .crosshair import Crosshair
|
|
||||||
from .entry_validator import EntryValidator
|
|
||||||
from .layout_manager import GridLayoutManager
|
|
||||||
from .rpc_decorator import register_rpc_methods, rpc_public
|
|
||||||
from .ui_loader import UILoader
|
|
||||||
from .validator_delegate import DoubleValidationDelegate
|
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ from pydantic import BaseModel, Field, field_validator
|
|||||||
from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, Signal
|
from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, Signal
|
||||||
from qtpy.QtWidgets import QApplication
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
|
||||||
from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot
|
from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot
|
||||||
from bec_widgets.utils.name_utils import sanitize_namespace
|
from bec_widgets.utils.name_utils import sanitize_namespace
|
||||||
|
from bec_widgets.utils.rpc_register import RPCRegister
|
||||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||||
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
|
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import importlib.metadata
|
|||||||
import inspect
|
import inspect
|
||||||
import pkgutil
|
import pkgutil
|
||||||
import traceback
|
import traceback
|
||||||
|
from functools import lru_cache
|
||||||
from importlib import util as importlib_util
|
from importlib import util as importlib_util
|
||||||
from importlib.machinery import FileFinder, ModuleSpec, SourceFileLoader
|
from importlib.machinery import FileFinder, ModuleSpec, SourceFileLoader
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
@@ -11,7 +12,11 @@ from typing import Generator
|
|||||||
|
|
||||||
from bec_lib.logger import bec_logger
|
from bec_lib.logger import bec_logger
|
||||||
|
|
||||||
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
|
from bec_widgets.utils.plugin_utils import (
|
||||||
|
BECClassContainer,
|
||||||
|
BECClassInfo,
|
||||||
|
rpc_widget_registry_from_source,
|
||||||
|
)
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
|
|
||||||
@@ -53,6 +58,14 @@ def _submodule_by_name(module: ModuleType, name: str):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _submodule_spec_by_name(module: ModuleType, name: str) -> ModuleSpec | None:
|
||||||
|
for module_info in pkgutil.iter_modules(module.__path__):
|
||||||
|
if module_info.name != name or not isinstance(module_info.module_finder, FileFinder):
|
||||||
|
continue
|
||||||
|
return module_info.module_finder.find_spec(module_info.name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _get_widgets_from_module(module: ModuleType) -> BECClassContainer:
|
def _get_widgets_from_module(module: ModuleType) -> BECClassContainer:
|
||||||
"""Find any BECWidget subclasses in the given module and return them with their info."""
|
"""Find any BECWidget subclasses in the given module and return them with their info."""
|
||||||
from bec_widgets.utils.bec_widget import BECWidget # avoid circular import
|
from bec_widgets.utils.bec_widget import BECWidget # avoid circular import
|
||||||
@@ -90,16 +103,64 @@ def get_plugin_client_module() -> ModuleType | None:
|
|||||||
return _submodule_by_name(plugin, "client") if (plugin := user_widget_plugin()) else None
|
return _submodule_by_name(plugin, "client") if (plugin := user_widget_plugin()) else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_plugin_designer_module() -> ModuleType | None:
|
||||||
|
"""If there is a plugin repository installed, return the designer module."""
|
||||||
|
return (
|
||||||
|
_submodule_by_name(plugin, "designer_plugins") if (plugin := user_widget_plugin()) else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_plugin_rpc_widget_registry() -> dict[str, tuple[str, str]]:
|
||||||
|
"""If there is a plugin repository installed, return the RPC widget registry."""
|
||||||
|
plugin = user_widget_plugin()
|
||||||
|
if plugin is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
client_spec = _submodule_spec_by_name(plugin, "client")
|
||||||
|
if client_spec is not None and client_spec.origin:
|
||||||
|
try:
|
||||||
|
return rpc_widget_registry_from_source(client_spec.origin)
|
||||||
|
except (OSError, SyntaxError) as exc:
|
||||||
|
logger.warning(f"Could not parse plugin RPC widget registry: {exc}")
|
||||||
|
|
||||||
|
client_module = get_plugin_client_module()
|
||||||
|
if client_module is None:
|
||||||
|
return {}
|
||||||
|
registry = {}
|
||||||
|
for plugin_name, plugin_class in inspect.getmembers(client_module, inspect.isclass):
|
||||||
|
if hasattr(plugin_class, "_IMPORT_MODULE"):
|
||||||
|
registry[plugin_name] = (plugin_class._IMPORT_MODULE, plugin_class.__name__)
|
||||||
|
return registry
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_plugin_designer_registry() -> dict[str, tuple[str, str]]:
|
||||||
|
"""If there is a plugin repository installed, return the designer plugin registry."""
|
||||||
|
designer_module = get_plugin_designer_module()
|
||||||
|
if designer_module and hasattr(designer_module, "designer_plugins"):
|
||||||
|
return designer_module.designer_plugins
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_plugin_widget_icons() -> dict[str, str]:
|
||||||
|
"""If there is a plugin repository installed, return the designer widget icon registry."""
|
||||||
|
designer_module = get_plugin_designer_module()
|
||||||
|
if designer_module and hasattr(designer_module, "widget_icons"):
|
||||||
|
return designer_module.widget_icons
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def get_all_plugin_widgets() -> BECClassContainer:
|
def get_all_plugin_widgets() -> BECClassContainer:
|
||||||
"""If there is a plugin repository installed, load all widgets from it."""
|
"""If there is a plugin repository installed, load all widgets from it."""
|
||||||
if plugin := user_widget_plugin():
|
if plugin := user_widget_plugin():
|
||||||
return _all_widgets_from_all_submods(plugin)
|
return _all_widgets_from_all_submods(plugin)
|
||||||
else:
|
return BECClassContainer()
|
||||||
return BECClassContainer()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
widgets = get_plugin_rpc_widget_registry()
|
||||||
client = get_plugin_client_module()
|
client = get_plugin_client_module()
|
||||||
print(get_all_plugin_widgets())
|
print(get_all_plugin_widgets())
|
||||||
...
|
...
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ from qtpy.QtGui import QFont, QPixmap
|
|||||||
from qtpy.QtWidgets import QApplication, QFileDialog, QLabel, QVBoxLayout, QWidget
|
from qtpy.QtWidgets import QApplication, QFileDialog, QLabel, QVBoxLayout, QWidget
|
||||||
|
|
||||||
import bec_widgets.widgets.containers.qt_ads as QtAds
|
import bec_widgets.widgets.containers.qt_ads as QtAds
|
||||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
|
||||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||||
from bec_widgets.utils.busy_loader import install_busy_loader
|
from bec_widgets.utils.busy_loader import install_busy_loader
|
||||||
from bec_widgets.utils.error_popups import SafeConnect, SafeSlot
|
from bec_widgets.utils.error_popups import SafeConnect, SafeSlot
|
||||||
from bec_widgets.utils.rpc_decorator import rpc_timeout
|
from bec_widgets.utils.rpc_decorator import rpc_timeout
|
||||||
|
from bec_widgets.utils.rpc_register import RPCRegister
|
||||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||||
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||||
|
|
||||||
|
|||||||
+90
-317
@@ -1,12 +1,12 @@
|
|||||||
"""Module for handling filter I/O operations in BEC Widgets for input fields.
|
"""Small helpers for populating editable combo boxes used by device inputs."""
|
||||||
These operations include filtering device/signal names and/or device types.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import nullcontext
|
||||||
|
|
||||||
from bec_lib.logger import bec_logger
|
from bec_lib.logger import bec_logger
|
||||||
from qtpy.QtCore import QStringListModel
|
from qtpy.QtCore import QSignalBlocker
|
||||||
from qtpy.QtWidgets import QComboBox, QCompleter, QLineEdit
|
from qtpy.QtWidgets import QComboBox
|
||||||
from typeguard import TypeCheckError
|
from typeguard import TypeCheckError
|
||||||
|
|
||||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||||
@@ -14,329 +14,102 @@ from bec_widgets.utils.ophyd_kind_util import Kind
|
|||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
|
|
||||||
|
|
||||||
class WidgetFilterHandler(ABC):
|
def replace_combobox_items(
|
||||||
"""Abstract base class for widget filter handlers"""
|
combo_box: QComboBox,
|
||||||
|
items: list[str | tuple],
|
||||||
|
*,
|
||||||
|
preserve_current_text: bool = False,
|
||||||
|
block_signals: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Replace all combobox entries.
|
||||||
|
|
||||||
@abstractmethod
|
Args:
|
||||||
def set_selection(self, widget, selection: list[str | tuple]) -> None:
|
combo_box: Combobox whose entries should be replaced.
|
||||||
"""Set the filtered_selection for the widget
|
items: Entries to add. String entries are added as display text. Tuple entries are
|
||||||
|
passed to ``QComboBox.addItem`` as ``(text, data)``.
|
||||||
Args:
|
preserve_current_text: If True, restore the combobox text after replacing the items.
|
||||||
widget: Widget instance
|
block_signals: If True, block combobox signals while the items are replaced.
|
||||||
selection (list[str | tuple]): Filtered selection of items.
|
|
||||||
If tuple, it contains (text, data) pairs.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def check_input(self, widget, text: str) -> bool:
|
|
||||||
"""Check if the input text is in the filtered selection
|
|
||||||
|
|
||||||
Args:
|
|
||||||
widget: Widget instance
|
|
||||||
text (str): Input text
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if the input text is in the filtered selection
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def update_with_kind(
|
|
||||||
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
|
|
||||||
) -> list[str | tuple]:
|
|
||||||
"""Update the selection based on the kind of signal.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
kind (Kind): The kind of signal to filter.
|
|
||||||
signal_filter (set): Set of signal kinds to filter.
|
|
||||||
device_info (dict): Dictionary containing device information.
|
|
||||||
device_name (str): Name of the device.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[str | tuple]: A list of filtered signals based on the kind.
|
|
||||||
"""
|
|
||||||
# This method should be implemented in subclasses or extended as needed
|
|
||||||
|
|
||||||
def update_with_bec_signal_class(
|
|
||||||
self,
|
|
||||||
signal_class_filter: str | list[str],
|
|
||||||
client,
|
|
||||||
ndim_filter: int | list[int] | None = None,
|
|
||||||
) -> list[tuple[str, str, dict]]:
|
|
||||||
"""Update the selection based on signal classes using device_manager.get_bec_signals.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
signal_class_filter (str|list[str]): List of signal class names to filter.
|
|
||||||
client: BEC client instance.
|
|
||||||
ndim_filter (int | list[int] | None): Filter signals by dimensionality.
|
|
||||||
If provided, only signals with matching ndim will be included.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[tuple[str, str, dict]]: A list of (device_name, signal_name, signal_config) tuples.
|
|
||||||
"""
|
|
||||||
if not client or not hasattr(client, "device_manager"):
|
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
|
||||||
signals = client.device_manager.get_bec_signals(signal_class_filter)
|
|
||||||
except TypeCheckError as e:
|
|
||||||
logger.warning(f"Error retrieving signals: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
if ndim_filter is None:
|
|
||||||
return signals
|
|
||||||
|
|
||||||
if isinstance(ndim_filter, int):
|
|
||||||
ndim_filter = [ndim_filter]
|
|
||||||
|
|
||||||
filtered_signals = []
|
|
||||||
for device_name, signal_name, signal_config in signals:
|
|
||||||
ndim = None
|
|
||||||
if isinstance(signal_config, dict):
|
|
||||||
ndim = signal_config.get("describe", {}).get("signal_info", {}).get("ndim")
|
|
||||||
|
|
||||||
if ndim in ndim_filter:
|
|
||||||
filtered_signals.append((device_name, signal_name, signal_config))
|
|
||||||
|
|
||||||
return filtered_signals
|
|
||||||
|
|
||||||
|
|
||||||
class LineEditFilterHandler(WidgetFilterHandler):
|
|
||||||
"""Handler for QLineEdit widget"""
|
|
||||||
|
|
||||||
def set_selection(self, widget: QLineEdit, selection: list[str | tuple]) -> None:
|
|
||||||
"""Set the selection for the widget to the completer model
|
|
||||||
|
|
||||||
Args:
|
|
||||||
widget (QLineEdit): The QLineEdit widget
|
|
||||||
selection (list[str | tuple]): Filtered selection of items. If tuple, it contains (text, data) pairs.
|
|
||||||
"""
|
|
||||||
if isinstance(selection, tuple):
|
|
||||||
# If selection is a tuple, it contains (text, data) pairs
|
|
||||||
selection = [text for text, _ in selection]
|
|
||||||
if not isinstance(widget.completer, QCompleter):
|
|
||||||
completer = QCompleter(widget)
|
|
||||||
widget.setCompleter(completer)
|
|
||||||
widget.completer.setModel(QStringListModel(selection, widget))
|
|
||||||
|
|
||||||
def check_input(self, widget: QLineEdit, text: str) -> bool:
|
|
||||||
"""Check if the input text is in the filtered selection
|
|
||||||
|
|
||||||
Args:
|
|
||||||
widget (QLineEdit): The QLineEdit widget
|
|
||||||
text (str): Input text
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if the input text is in the filtered selection
|
|
||||||
"""
|
|
||||||
model = widget.completer.model()
|
|
||||||
model_data = [model.data(model.index(i)) for i in range(model.rowCount())]
|
|
||||||
return text in model_data
|
|
||||||
|
|
||||||
def update_with_kind(
|
|
||||||
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
|
|
||||||
) -> list[str | tuple]:
|
|
||||||
"""Update the selection based on the kind of signal.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
kind (Kind): The kind of signal to filter.
|
|
||||||
signal_filter (set): Set of signal kinds to filter.
|
|
||||||
device_info (dict): Dictionary containing device information.
|
|
||||||
device_name (str): Name of the device.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[str | tuple]: A list of filtered signals based on the kind.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return [
|
|
||||||
signal
|
|
||||||
for signal, signal_info in device_info.items()
|
|
||||||
if kind in signal_filter and (signal_info.get("kind_str", None) == str(kind.name))
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class ComboBoxFilterHandler(WidgetFilterHandler):
|
|
||||||
"""Handler for QComboBox widget"""
|
|
||||||
|
|
||||||
def set_selection(self, widget: QComboBox, selection: list[str | tuple]) -> None:
|
|
||||||
"""Set the selection for the widget to the completer model
|
|
||||||
|
|
||||||
Args:
|
|
||||||
widget (QComboBox): The QComboBox widget
|
|
||||||
selection (list[str | tuple]): Filtered selection of items. If tuple, it contains (text, data) pairs.
|
|
||||||
"""
|
|
||||||
widget.clear()
|
|
||||||
if len(selection) == 0:
|
|
||||||
return
|
|
||||||
for element in selection:
|
|
||||||
if isinstance(element, str):
|
|
||||||
widget.addItem(element)
|
|
||||||
elif isinstance(element, tuple):
|
|
||||||
# If element is a tuple, it contains (text, data) pairs
|
|
||||||
widget.addItem(*element)
|
|
||||||
|
|
||||||
def check_input(self, widget: QComboBox, text: str) -> bool:
|
|
||||||
"""Check if the input text is in the filtered selection
|
|
||||||
|
|
||||||
Args:
|
|
||||||
widget (QComboBox): The QComboBox widget
|
|
||||||
text (str): Input text
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if the input text is in the filtered selection
|
|
||||||
"""
|
|
||||||
return text in [widget.itemText(i) for i in range(widget.count())]
|
|
||||||
|
|
||||||
def update_with_kind(
|
|
||||||
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
|
|
||||||
) -> list[str | tuple]:
|
|
||||||
"""Update the selection based on the kind of signal.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
kind (Kind): The kind of signal to filter.
|
|
||||||
signal_filter (set): Set of signal kinds to filter.
|
|
||||||
device_info (dict): Dictionary containing device information.
|
|
||||||
device_name (str): Name of the device.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[str | tuple]: A list of filtered signals based on the kind.
|
|
||||||
"""
|
|
||||||
out = []
|
|
||||||
for signal, signal_info in device_info.items():
|
|
||||||
if kind not in signal_filter or (signal_info.get("kind_str", None) != str(kind.name)):
|
|
||||||
continue
|
|
||||||
obj_name = signal_info.get("obj_name", "")
|
|
||||||
component_name = signal_info.get("component_name", "")
|
|
||||||
signal_wo_device = obj_name.removeprefix(f"{device_name}_")
|
|
||||||
if not signal_wo_device:
|
|
||||||
signal_wo_device = obj_name
|
|
||||||
|
|
||||||
if signal_wo_device != signal and component_name.replace(".", "_") != signal_wo_device:
|
|
||||||
# If the object name is not the same as the signal name, we use the object name
|
|
||||||
# to display in the combobox.
|
|
||||||
out.append((f"{signal_wo_device} ({signal})", signal_info))
|
|
||||||
else:
|
|
||||||
# If the object name is the same as the signal name, we do not change it.
|
|
||||||
out.append((signal, signal_info))
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
class FilterIO:
|
|
||||||
"""Public interface to set filters for input widgets.
|
|
||||||
It supports the list of widgets stored in class attribute _handlers.
|
|
||||||
"""
|
"""
|
||||||
|
current_text = combo_box.currentText()
|
||||||
|
signal_blocker = QSignalBlocker(combo_box) if block_signals else nullcontext()
|
||||||
|
with signal_blocker:
|
||||||
|
combo_box.clear()
|
||||||
|
for item in items:
|
||||||
|
if isinstance(item, str):
|
||||||
|
combo_box.addItem(item)
|
||||||
|
else:
|
||||||
|
combo_box.addItem(*item)
|
||||||
|
if preserve_current_text:
|
||||||
|
combo_box.setCurrentText(current_text)
|
||||||
|
|
||||||
_handlers = {QLineEdit: LineEditFilterHandler, QComboBox: ComboBoxFilterHandler}
|
|
||||||
|
|
||||||
@staticmethod
|
def signal_items_for_kind(
|
||||||
def set_selection(widget, selection: list[str | tuple], ignore_errors=True):
|
*, kind: Kind, signal_filter: set[Kind], device_info: dict, device_name: str
|
||||||
"""
|
) -> list[tuple[str, dict]]:
|
||||||
Retrieve value from the widget instance.
|
"""Build display entries for signals matching a BEC signal kind.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
widget: Widget instance.
|
kind: Signal kind to collect.
|
||||||
selection (list[str | tuple]): Filtered selection of items.
|
signal_filter: Enabled signal kinds.
|
||||||
If tuple, it contains (text, data) pairs.
|
device_info: Signal metadata from the BEC device info dictionary.
|
||||||
ignore_errors(bool, optional): Whether to ignore if no handler is found.
|
device_name: Name of the device owning the signals.
|
||||||
"""
|
|
||||||
handler_class = FilterIO._find_handler(widget)
|
|
||||||
if handler_class:
|
|
||||||
return handler_class().set_selection(widget=widget, selection=selection)
|
|
||||||
if not ignore_errors:
|
|
||||||
raise ValueError(
|
|
||||||
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
Returns:
|
||||||
def check_input(widget, text: str, ignore_errors=True):
|
Combobox entries as ``(display_text, signal_info)`` tuples.
|
||||||
"""
|
"""
|
||||||
Check if the input text is in the filtered selection.
|
items: list[tuple[str, dict]] = []
|
||||||
|
for signal_name, signal_info in device_info.items():
|
||||||
|
if kind not in signal_filter or signal_info.get("kind_str") != kind.name:
|
||||||
|
continue
|
||||||
|
|
||||||
Args:
|
obj_name = signal_info.get("obj_name", "")
|
||||||
widget: Widget instance.
|
component_name = signal_info.get("component_name", "")
|
||||||
text(str): Input text.
|
signal_without_device = obj_name.removeprefix(f"{device_name}_")
|
||||||
ignore_errors(bool, optional): Whether to ignore if no handler is found.
|
if not signal_without_device:
|
||||||
|
signal_without_device = obj_name
|
||||||
|
|
||||||
Returns:
|
if (
|
||||||
bool: True if the input text is in the filtered selection.
|
signal_without_device != signal_name
|
||||||
"""
|
and component_name.replace(".", "_") != signal_without_device
|
||||||
handler_class = FilterIO._find_handler(widget)
|
):
|
||||||
if handler_class:
|
items.append((f"{signal_without_device} ({signal_name})", signal_info))
|
||||||
return handler_class().check_input(widget=widget, text=text)
|
else:
|
||||||
if not ignore_errors:
|
items.append((signal_name, signal_info))
|
||||||
raise ValueError(
|
return items
|
||||||
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def update_with_kind(
|
|
||||||
widget, kind: Kind, signal_filter: set, device_info: dict, device_name: str
|
|
||||||
) -> list[str | tuple]:
|
|
||||||
"""
|
|
||||||
Update the selection based on the kind of signal.
|
|
||||||
|
|
||||||
Args:
|
def get_bec_signals_for_classes(
|
||||||
widget: Widget instance.
|
*, client, signal_class_filter: str | list[str], ndim_filter: int | list[int] | None = None
|
||||||
kind (Kind): The kind of signal to filter.
|
) -> list[tuple[str, str, dict]]:
|
||||||
signal_filter (set): Set of signal kinds to filter.
|
"""Return BEC signals filtered by signal class and optional dimensionality.
|
||||||
device_info (dict): Dictionary containing device information.
|
|
||||||
device_name (str): Name of the device.
|
|
||||||
|
|
||||||
Returns:
|
Args:
|
||||||
list[str | tuple]: A list of filtered signals based on the kind.
|
client: BEC client that provides ``device_manager.get_bec_signals``.
|
||||||
"""
|
signal_class_filter: Signal class name or class names passed to the device manager.
|
||||||
handler_class = FilterIO._find_handler(widget)
|
ndim_filter: Optional dimensionality filter. If provided, only signals whose
|
||||||
if handler_class:
|
``describe.signal_info.ndim`` is in this value are returned.
|
||||||
return handler_class().update_with_kind(
|
|
||||||
kind=kind,
|
|
||||||
signal_filter=signal_filter,
|
|
||||||
device_info=device_info,
|
|
||||||
device_name=device_name,
|
|
||||||
)
|
|
||||||
raise ValueError(
|
|
||||||
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
Returns:
|
||||||
def update_with_signal_class(
|
Tuples of ``(device_name, signal_name, signal_config)`` for matching signals.
|
||||||
widget, signal_class_filter: list[str], client, ndim_filter: int | list[int] | None = None
|
"""
|
||||||
) -> list[tuple[str, str, dict]]:
|
if not client or not hasattr(client, "device_manager"):
|
||||||
"""
|
return []
|
||||||
Update the selection based on signal classes using device_manager.get_bec_signals.
|
|
||||||
|
|
||||||
Args:
|
try:
|
||||||
widget: Widget instance.
|
signals = client.device_manager.get_bec_signals(signal_class_filter)
|
||||||
signal_class_filter (list[str]): List of signal class names to filter.
|
except TypeCheckError as exc:
|
||||||
client: BEC client instance.
|
logger.warning(f"Error retrieving signals: {exc}")
|
||||||
ndim_filter (int | list[int] | None): Filter signals by dimensionality.
|
return []
|
||||||
If provided, only signals with matching ndim will be included.
|
|
||||||
|
|
||||||
Returns:
|
if ndim_filter is None:
|
||||||
list[tuple[str, str, dict]]: A list of (device_name, signal_name, signal_config) tuples.
|
return signals
|
||||||
"""
|
|
||||||
handler_class = FilterIO._find_handler(widget)
|
|
||||||
if handler_class:
|
|
||||||
return handler_class().update_with_bec_signal_class(
|
|
||||||
signal_class_filter=signal_class_filter, client=client, ndim_filter=ndim_filter
|
|
||||||
)
|
|
||||||
raise ValueError(
|
|
||||||
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
accepted_ndim = [ndim_filter] if isinstance(ndim_filter, int) else ndim_filter
|
||||||
def _find_handler(widget):
|
filtered_signals: list[tuple[str, str, dict]] = []
|
||||||
"""
|
for device_name, signal_name, signal_config in signals:
|
||||||
Find the appropriate handler for the widget by checking its base classes.
|
ndim = None
|
||||||
|
if isinstance(signal_config, dict):
|
||||||
Args:
|
ndim = signal_config.get("describe", {}).get("signal_info", {}).get("ndim")
|
||||||
widget: Widget instance.
|
if ndim in accepted_ndim:
|
||||||
|
filtered_signals.append((device_name, signal_name, signal_config))
|
||||||
Returns:
|
return filtered_signals
|
||||||
handler_class: The handler class if found, otherwise None.
|
|
||||||
"""
|
|
||||||
for base in type(widget).__mro__:
|
|
||||||
if base in FilterIO._handlers:
|
|
||||||
return FilterIO._handlers[base]
|
|
||||||
return None
|
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ import isort
|
|||||||
from bec_lib.logger import bec_logger
|
from bec_lib.logger import bec_logger
|
||||||
from qtpy.QtCore import Property as QtProperty
|
from qtpy.QtCore import Property as QtProperty
|
||||||
|
|
||||||
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator, plugin_filenames
|
from bec_widgets.utils.generate_designer_plugin import (
|
||||||
|
DesignerPluginGenerator,
|
||||||
|
DesignerPluginInfo,
|
||||||
|
plugin_filenames,
|
||||||
|
)
|
||||||
from bec_widgets.utils.plugin_utils import BECClassContainer, get_custom_classes
|
from bec_widgets.utils.plugin_utils import BECClassContainer, get_custom_classes
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
@@ -250,6 +254,58 @@ class {class_name}(RPCBase):\n"""
|
|||||||
file.write(formatted_content)
|
file.write(formatted_content)
|
||||||
|
|
||||||
|
|
||||||
|
def write_designer_plugins(plugin_infos: list[DesignerPluginInfo], file_name: str):
|
||||||
|
"""
|
||||||
|
Write a registry of Qt widget classes with designer plugins.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_infos(list[DesignerPluginInfo]): The designer plugin metadata to write.
|
||||||
|
file_name(str): The name of the file to write to.
|
||||||
|
"""
|
||||||
|
plugin_infos = sorted(plugin_infos, key=lambda info: info.plugin_name_pascal)
|
||||||
|
content = """# This file was automatically generated by generate_cli.py
|
||||||
|
# type: ignore
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# pylint: skip-file
|
||||||
|
|
||||||
|
designer_plugins = {
|
||||||
|
"""
|
||||||
|
for info in plugin_infos:
|
||||||
|
widget_module = info.plugin_class.__module__
|
||||||
|
widget_class = info.plugin_name_pascal
|
||||||
|
content += f' "{info.plugin_name_pascal}": ("{widget_module}", "{widget_class}"),\n'
|
||||||
|
|
||||||
|
content += """
|
||||||
|
}
|
||||||
|
|
||||||
|
widget_icons = {
|
||||||
|
"""
|
||||||
|
for info in plugin_infos:
|
||||||
|
content += f' "{info.plugin_name_pascal}": "{info.icon_name}",\n'
|
||||||
|
|
||||||
|
content += """
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
formatted_content = black.format_str(content, mode=black.Mode(line_length=100))
|
||||||
|
except black.NothingChanged:
|
||||||
|
formatted_content = content
|
||||||
|
|
||||||
|
config = isort.Config(
|
||||||
|
profile="black",
|
||||||
|
line_length=100,
|
||||||
|
multi_line_output=3,
|
||||||
|
include_trailing_comma=False,
|
||||||
|
known_first_party=["bec_widgets"],
|
||||||
|
)
|
||||||
|
formatted_content = isort.code(formatted_content, config=config)
|
||||||
|
|
||||||
|
with open(file_name, "w", encoding="utf-8") as file:
|
||||||
|
file.write(formatted_content)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""
|
"""
|
||||||
Main entry point for the script, controlled by command line arguments.
|
Main entry point for the script, controlled by command line arguments.
|
||||||
@@ -303,6 +359,8 @@ def main():
|
|||||||
else:
|
else:
|
||||||
non_overwrite_classes = []
|
non_overwrite_classes = []
|
||||||
|
|
||||||
|
designer_plugin_infos = []
|
||||||
|
|
||||||
for cls in rpc_classes.plugins:
|
for cls in rpc_classes.plugins:
|
||||||
logger.info(f"Writing bec-designer plugin files for {cls.__name__}...")
|
logger.info(f"Writing bec-designer plugin files for {cls.__name__}...")
|
||||||
|
|
||||||
@@ -310,21 +368,30 @@ def main():
|
|||||||
logger.error(
|
logger.error(
|
||||||
f"Not writing plugin files for {cls.__name__} because a built-in widget with that name exists"
|
f"Not writing plugin files for {cls.__name__} because a built-in widget with that name exists"
|
||||||
)
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
plugin = DesignerPluginGenerator(cls)
|
plugin = DesignerPluginGenerator(cls)
|
||||||
if not hasattr(plugin, "info"):
|
if not hasattr(plugin, "info") or plugin.excluded:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
def _exists(file: str):
|
def _exists(file: str):
|
||||||
return os.path.exists(os.path.join(plugin.info.base_path, file))
|
return os.path.exists(os.path.join(plugin.info.base_path, file))
|
||||||
|
|
||||||
if any(_exists(file) for file in plugin_filenames(plugin.info.plugin_name_snake)):
|
if any(_exists(file) for file in plugin_filenames(plugin.info.plugin_name_snake)):
|
||||||
|
if _exists(plugin.filenames.plugin):
|
||||||
|
designer_plugin_infos.append(plugin.info)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Skipping generation of extra plugin files for {plugin.info.plugin_name_snake} - at least one file out of 'plugin.py', 'pyproject', and 'register_{plugin.info.plugin_name_snake}.py' already exists."
|
f"Skipping generation of extra plugin files for {plugin.info.plugin_name_snake} - at least one file out of 'plugin.py', 'pyproject', and 'register_{plugin.info.plugin_name_snake}.py' already exists."
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
plugin.run()
|
plugin.run()
|
||||||
|
designer_plugin_infos.append(plugin.info)
|
||||||
|
|
||||||
|
# Write designer_plugins.py with plugin import metadata for all widgets with designer plugins.
|
||||||
|
designer_plugins_path = module_dir / client_subdir / "designer_plugins.py"
|
||||||
|
logger.info(f"Generating designer plugin registry at {designer_plugins_path}")
|
||||||
|
write_designer_plugins(designer_plugin_infos, str(designer_plugins_path))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
@@ -29,6 +29,7 @@ class DesignerPluginInfo:
|
|||||||
self.plugin_name_pascal = plugin_class.__name__
|
self.plugin_name_pascal = plugin_class.__name__
|
||||||
self.plugin_name_snake = pascal_to_snake(self.plugin_name_pascal)
|
self.plugin_name_snake = pascal_to_snake(self.plugin_name_pascal)
|
||||||
self.widget_import = f"from {plugin_class.__module__} import {self.plugin_name_pascal}"
|
self.widget_import = f"from {plugin_class.__module__} import {self.plugin_name_pascal}"
|
||||||
|
self.icon_name = getattr(plugin_class, "ICON_NAME", "")
|
||||||
plugin_module = (
|
plugin_module = (
|
||||||
".".join(plugin_class.__module__.split(".")[:-1]) + f".{self.plugin_name_snake}_plugin"
|
".".join(plugin_class.__module__.split(".")[:-1]) + f".{self.plugin_name_snake}_plugin"
|
||||||
)
|
)
|
||||||
@@ -63,6 +64,10 @@ class DesignerPluginGenerator:
|
|||||||
def filenames(self):
|
def filenames(self):
|
||||||
return plugin_filenames(self.info.plugin_name_snake)
|
return plugin_filenames(self.info.plugin_name_snake)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def excluded(self):
|
||||||
|
return self._excluded
|
||||||
|
|
||||||
def run(self, validate=True):
|
def run(self, validate=True):
|
||||||
if self._excluded:
|
if self._excluded:
|
||||||
print(f"Plugin {self.widget.__name__} is excluded from generation.")
|
print(f"Plugin {self.widget.__name__} is excluded from generation.")
|
||||||
|
|||||||
@@ -1,56 +1,22 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
import importlib
|
import importlib
|
||||||
import inspect
|
import inspect
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Iterable
|
from typing import TYPE_CHECKING, Iterable
|
||||||
|
|
||||||
from bec_lib.plugin_helper import _get_available_plugins
|
|
||||||
from qtpy.QtWidgets import QWidget
|
|
||||||
|
|
||||||
from bec_widgets.utils import BECConnector
|
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
|
from qtpy.QtWidgets import QWidget
|
||||||
|
|
||||||
|
from bec_widgets.utils.bec_connector import BECConnector
|
||||||
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
|
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
|
||||||
|
|
||||||
|
|
||||||
def get_plugin_widgets() -> dict[str, BECConnector]:
|
|
||||||
"""
|
|
||||||
Get all available widgets from the plugin directory. Widgets are classes that inherit from BECConnector.
|
|
||||||
The plugins are provided through python plugins and specified in the respective pyproject.toml file using
|
|
||||||
the following key:
|
|
||||||
|
|
||||||
[project.entry-points."bec.widgets.user_widgets"]
|
|
||||||
plugin_widgets = "path.to.plugin.module"
|
|
||||||
|
|
||||||
e.g.
|
|
||||||
[project.entry-points."bec.widgets.user_widgets"]
|
|
||||||
plugin_widgets = "pxiii_bec.bec_widgets.widgets"
|
|
||||||
|
|
||||||
assuming that the widgets module for the package pxiii_bec is located at pxiii_bec/bec_widgets/widgets and
|
|
||||||
contains the widgets to be loaded within the pxiii_bec/bec_widgets/widgets/__init__.py file.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict[str, BECConnector]: A dictionary of widget names and their respective classes.
|
|
||||||
"""
|
|
||||||
modules = _get_available_plugins("bec.widgets.user_widgets")
|
|
||||||
loaded_plugins = {}
|
|
||||||
print(modules)
|
|
||||||
for module in modules:
|
|
||||||
mods = inspect.getmembers(module, predicate=_filter_plugins)
|
|
||||||
for name, mod_cls in mods:
|
|
||||||
if name in loaded_plugins:
|
|
||||||
print(f"Duplicated widgets plugin {name}.")
|
|
||||||
loaded_plugins[name] = mod_cls
|
|
||||||
return loaded_plugins
|
|
||||||
|
|
||||||
|
|
||||||
def _filter_plugins(obj):
|
|
||||||
return inspect.isclass(obj) and issubclass(obj, BECConnector)
|
|
||||||
|
|
||||||
|
|
||||||
def get_plugin_auto_updates() -> dict[str, type[AutoUpdates]]:
|
def get_plugin_auto_updates() -> dict[str, type[AutoUpdates]]:
|
||||||
"""
|
"""
|
||||||
Get all available auto update classes from the plugin directory. AutoUpdates must inherit from AutoUpdate and be
|
Get all available auto update classes from the plugin directory. AutoUpdates must inherit from AutoUpdate and be
|
||||||
@@ -66,6 +32,8 @@ def get_plugin_auto_updates() -> dict[str, type[AutoUpdates]]:
|
|||||||
Returns:
|
Returns:
|
||||||
dict[str, AutoUpdates]: A dictionary of widget names and their respective classes.
|
dict[str, AutoUpdates]: A dictionary of widget names and their respective classes.
|
||||||
"""
|
"""
|
||||||
|
from bec_lib.plugin_helper import _get_available_plugins
|
||||||
|
|
||||||
modules = _get_available_plugins("bec.widgets.auto_updates")
|
modules = _get_available_plugins("bec.widgets.auto_updates")
|
||||||
loaded_plugins = {}
|
loaded_plugins = {}
|
||||||
for module in modules:
|
for module in modules:
|
||||||
@@ -168,6 +136,11 @@ class BECClassContainer:
|
|||||||
|
|
||||||
def _collect_classes_from_package(repo_name: str, package: str) -> BECClassContainer:
|
def _collect_classes_from_package(repo_name: str, package: str) -> BECClassContainer:
|
||||||
"""Collect classes from a package subtree (for example ``widgets`` or ``applications``)."""
|
"""Collect classes from a package subtree (for example ``widgets`` or ``applications``)."""
|
||||||
|
from qtpy.QtWidgets import QWidget
|
||||||
|
|
||||||
|
from bec_widgets.utils.bec_connector import BECConnector
|
||||||
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
|
|
||||||
collection = BECClassContainer()
|
collection = BECClassContainer()
|
||||||
try:
|
try:
|
||||||
anchor_module = importlib.import_module(f"{repo_name}.{package}")
|
anchor_module = importlib.import_module(f"{repo_name}.{package}")
|
||||||
@@ -194,17 +167,18 @@ def _collect_classes_from_package(repo_name: str, package: str) -> BECClassConta
|
|||||||
|
|
||||||
for name in dir(module):
|
for name in dir(module):
|
||||||
obj = getattr(module, name)
|
obj = getattr(module, name)
|
||||||
|
if not isinstance(obj, type):
|
||||||
|
continue
|
||||||
if not hasattr(obj, "__module__") or obj.__module__ != module.__name__:
|
if not hasattr(obj, "__module__") or obj.__module__ != module.__name__:
|
||||||
continue
|
continue
|
||||||
if isinstance(obj, type):
|
class_info = BECClassInfo(name=name, module=module.__name__, file=path, obj=obj)
|
||||||
class_info = BECClassInfo(name=name, module=module.__name__, file=path, obj=obj)
|
if issubclass(obj, BECConnector):
|
||||||
if issubclass(obj, BECConnector):
|
class_info.is_connector = True
|
||||||
class_info.is_connector = True
|
if issubclass(obj, QWidget) or issubclass(obj, BECWidget):
|
||||||
if issubclass(obj, QWidget) or issubclass(obj, BECWidget):
|
class_info.is_widget = True
|
||||||
class_info.is_widget = True
|
if hasattr(obj, "PLUGIN") and obj.PLUGIN:
|
||||||
if hasattr(obj, "PLUGIN") and obj.PLUGIN:
|
class_info.is_plugin = True
|
||||||
class_info.is_plugin = True
|
collection.add_class(class_info)
|
||||||
collection.add_class(class_info)
|
|
||||||
return collection
|
return collection
|
||||||
|
|
||||||
|
|
||||||
@@ -229,3 +203,89 @@ def get_custom_classes(
|
|||||||
for package in selected_packages:
|
for package in selected_packages:
|
||||||
collection += _collect_classes_from_package(repo_name, package)
|
collection += _collect_classes_from_package(repo_name, package)
|
||||||
return collection
|
return collection
|
||||||
|
|
||||||
|
|
||||||
|
def _get_designer_registry() -> dict[str, tuple[str, str]]:
|
||||||
|
from bec_widgets.cli.designer_plugins import designer_plugins
|
||||||
|
|
||||||
|
return designer_plugins
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_widget_from_registry(import_path: str, widget_name: str) -> type[QWidget]:
|
||||||
|
widget = importlib.import_module(import_path)
|
||||||
|
return getattr(widget, widget_name)
|
||||||
|
|
||||||
|
|
||||||
|
def designer_plugin_exists(name: str) -> bool:
|
||||||
|
from bec_widgets.utils.bec_plugin_helper import get_plugin_designer_registry
|
||||||
|
|
||||||
|
internal_registry = _get_designer_registry()
|
||||||
|
external_registry = get_plugin_designer_registry()
|
||||||
|
return name in internal_registry or name in external_registry
|
||||||
|
|
||||||
|
|
||||||
|
def get_designer_plugin(name: str, raise_on_missing: bool = True) -> type[QWidget] | None:
|
||||||
|
from bec_widgets.utils.bec_plugin_helper import get_plugin_designer_registry
|
||||||
|
|
||||||
|
internal_registry = _get_designer_registry()
|
||||||
|
external_registry = get_plugin_designer_registry()
|
||||||
|
if name in external_registry:
|
||||||
|
import_path, widget_name = external_registry[name]
|
||||||
|
return _resolve_widget_from_registry(import_path, widget_name)
|
||||||
|
if name in internal_registry:
|
||||||
|
import_path, widget_name = internal_registry[name]
|
||||||
|
return _resolve_widget_from_registry(import_path, widget_name)
|
||||||
|
|
||||||
|
if raise_on_missing:
|
||||||
|
raise ValueError(
|
||||||
|
f"Designer plugin {name} not found in either internal or external registry."
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def rpc_widget_registry_from_source(path: str | Path) -> dict[str, tuple[str, str]]:
|
||||||
|
"""Parse a generated RPC client module and return its widget registry."""
|
||||||
|
source_path = Path(path)
|
||||||
|
module_node = ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path))
|
||||||
|
registry = {}
|
||||||
|
for node in module_node.body:
|
||||||
|
if not isinstance(node, ast.ClassDef):
|
||||||
|
continue
|
||||||
|
for item in node.body:
|
||||||
|
if not isinstance(item, ast.Assign):
|
||||||
|
continue
|
||||||
|
if not any(
|
||||||
|
isinstance(target, ast.Name) and target.id == "_IMPORT_MODULE"
|
||||||
|
for target in item.targets
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if isinstance(item.value, ast.Constant) and isinstance(item.value.value, str):
|
||||||
|
registry[node.name] = (item.value.value, node.name)
|
||||||
|
break
|
||||||
|
return registry
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_rpc_widget_registry() -> dict[str, tuple[str, str]]:
|
||||||
|
client_path = Path(__file__).resolve().parents[1] / "cli" / "client.py"
|
||||||
|
return rpc_widget_registry_from_source(client_path)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def rpc_widget_registry() -> dict[str, tuple[str, str]]:
|
||||||
|
from bec_widgets.utils.bec_plugin_helper import get_plugin_rpc_widget_registry
|
||||||
|
|
||||||
|
internal_registry = get_rpc_widget_registry()
|
||||||
|
external_registry = get_plugin_rpc_widget_registry()
|
||||||
|
return {**external_registry, **internal_registry}
|
||||||
|
|
||||||
|
|
||||||
|
def get_rpc_widget(name: str, raise_on_missing: bool = True) -> type[QWidget] | None:
|
||||||
|
registry = rpc_widget_registry()
|
||||||
|
if name in registry:
|
||||||
|
import_path, widget_name = registry[name]
|
||||||
|
return _resolve_widget_from_registry(import_path, widget_name)
|
||||||
|
|
||||||
|
if raise_on_missing:
|
||||||
|
raise ValueError(f"RPC widget {name} not found in registry.")
|
||||||
|
return None
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ from qtpy.QtCore import Qt, QTimer
|
|||||||
from qtpy.QtWidgets import QWidget
|
from qtpy.QtWidgets import QWidget
|
||||||
from redis.exceptions import RedisError
|
from redis.exceptions import RedisError
|
||||||
|
|
||||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
|
||||||
from bec_widgets.utils import BECDispatcher
|
|
||||||
from bec_widgets.utils.bec_connector import BECConnector
|
from bec_widgets.utils.bec_connector import BECConnector
|
||||||
|
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||||
from bec_widgets.utils.error_popups import ErrorPopupUtility
|
from bec_widgets.utils.error_popups import ErrorPopupUtility
|
||||||
|
from bec_widgets.utils.rpc_register import RPCRegister
|
||||||
from bec_widgets.utils.screen_utils import apply_window_geometry
|
from bec_widgets.utils.screen_utils import apply_window_geometry
|
||||||
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
|
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
|
||||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC
|
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from bec_widgets.utils.plugin_utils import get_rpc_widget, rpc_widget_registry
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
|
|
||||||
|
|
||||||
|
class RPCWidgetHandler:
|
||||||
|
"""Handler class for creating widgets from RPC messages."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._widget_registry = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def widget_classes(self) -> dict[str, tuple[str, str]]:
|
||||||
|
"""
|
||||||
|
Get the available widget classes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: The available widget classes.
|
||||||
|
"""
|
||||||
|
registry = rpc_widget_registry()
|
||||||
|
if not registry:
|
||||||
|
return {}
|
||||||
|
return registry
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_widget(widget_type, **kwargs) -> BECWidget:
|
||||||
|
"""
|
||||||
|
Create a widget from an RPC message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
widget_type(str): The type of the widget.
|
||||||
|
name (str): The name of the widget.
|
||||||
|
**kwargs: The keyword arguments for the widget.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
widget(BECWidget): The created widget.
|
||||||
|
"""
|
||||||
|
widget = get_rpc_widget(widget_type, raise_on_missing=False)
|
||||||
|
if widget:
|
||||||
|
return widget(**kwargs)
|
||||||
|
raise ValueError(f"Unknown widget type: {widget_type}")
|
||||||
|
|
||||||
|
|
||||||
|
widget_handler = RPCWidgetHandler()
|
||||||
@@ -27,8 +27,10 @@ from qtpy.QtWidgets import (
|
|||||||
|
|
||||||
import bec_widgets
|
import bec_widgets
|
||||||
from bec_widgets.utils.toolbars.splitter import ResizableSpacer
|
from bec_widgets.utils.toolbars.splitter import ResizableSpacer
|
||||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
|
||||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
BECDeviceFilter,
|
||||||
|
DeviceComboBox,
|
||||||
|
)
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
from bec_lib.logger import bec_logger
|
from bec_lib.logger import bec_logger
|
||||||
from qtpy import PYQT6, PYSIDE6
|
from qtpy import PYSIDE6
|
||||||
from qtpy.QtCore import QFile, QIODevice
|
from qtpy.QtCore import QFile, QIODevice
|
||||||
|
|
||||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
from bec_widgets.utils.plugin_utils import get_designer_plugin
|
||||||
from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo
|
|
||||||
from bec_widgets.utils.plugin_utils import get_custom_classes
|
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
|
|
||||||
@@ -12,16 +10,14 @@ if PYSIDE6:
|
|||||||
from qtpy.QtUiTools import QUiLoader
|
from qtpy.QtUiTools import QUiLoader
|
||||||
|
|
||||||
class CustomUiLoader(QUiLoader):
|
class CustomUiLoader(QUiLoader):
|
||||||
def __init__(self, baseinstance, custom_widgets: dict | None = None):
|
def __init__(self, baseinstance):
|
||||||
super().__init__(baseinstance)
|
super().__init__(baseinstance)
|
||||||
self.custom_widgets = custom_widgets or {}
|
|
||||||
|
|
||||||
self.baseinstance = baseinstance
|
self.baseinstance = baseinstance
|
||||||
|
|
||||||
def createWidget(self, class_name, parent=None, name=""):
|
def createWidget(self, class_name, parent=None, name=""):
|
||||||
if class_name in self.custom_widgets:
|
widget = get_designer_plugin(class_name, raise_on_missing=False)
|
||||||
widget = self.custom_widgets[class_name](self.baseinstance)
|
if widget is not None:
|
||||||
return widget
|
return widget(self.baseinstance)
|
||||||
return super().createWidget(class_name, self.baseinstance, name)
|
return super().createWidget(class_name, self.baseinstance, name)
|
||||||
|
|
||||||
|
|
||||||
@@ -31,16 +27,9 @@ class UILoader:
|
|||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
|
|
||||||
self.custom_widgets = (
|
if not PYSIDE6:
|
||||||
get_custom_classes("bec_widgets") + get_all_plugin_widgets()
|
|
||||||
).as_dict()
|
|
||||||
|
|
||||||
if PYSIDE6:
|
|
||||||
self.loader = self.load_ui_pyside6
|
|
||||||
elif PYQT6:
|
|
||||||
self.loader = self.load_ui_pyqt6
|
|
||||||
else:
|
|
||||||
raise ImportError("No compatible Qt bindings found.")
|
raise ImportError("No compatible Qt bindings found.")
|
||||||
|
self.loader = self.load_ui_pyside6
|
||||||
|
|
||||||
def load_ui_pyside6(self, ui_file, parent=None):
|
def load_ui_pyside6(self, ui_file, parent=None):
|
||||||
"""
|
"""
|
||||||
@@ -53,7 +42,7 @@ class UILoader:
|
|||||||
QWidget: The loaded widget.
|
QWidget: The loaded widget.
|
||||||
"""
|
"""
|
||||||
parent = parent or self.parent
|
parent = parent or self.parent
|
||||||
loader = CustomUiLoader(parent, self.custom_widgets)
|
loader = CustomUiLoader(parent)
|
||||||
file = QFile(ui_file)
|
file = QFile(ui_file)
|
||||||
if not file.open(QIODevice.ReadOnly):
|
if not file.open(QIODevice.ReadOnly):
|
||||||
raise IOError(f"Cannot open file: {ui_file}")
|
raise IOError(f"Cannot open file: {ui_file}")
|
||||||
@@ -61,71 +50,6 @@ class UILoader:
|
|||||||
file.close()
|
file.close()
|
||||||
return widget
|
return widget
|
||||||
|
|
||||||
def load_ui_pyqt6(self, ui_file, parent=None):
|
|
||||||
"""
|
|
||||||
Specific loader for PyQt6 using loadUi.
|
|
||||||
Args:
|
|
||||||
ui_file(str): Path to the .ui file.
|
|
||||||
parent(QWidget): Parent widget.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
QWidget: The loaded widget.
|
|
||||||
"""
|
|
||||||
from PyQt6.uic.Loader.loader import DynamicUILoader
|
|
||||||
|
|
||||||
class CustomDynamicUILoader(DynamicUILoader):
|
|
||||||
def __init__(self, package, custom_widgets: dict = None):
|
|
||||||
super().__init__(package)
|
|
||||||
self.custom_widgets = custom_widgets or {}
|
|
||||||
|
|
||||||
def _handle_custom_widgets(self, el):
|
|
||||||
"""Handle the <customwidgets> element."""
|
|
||||||
|
|
||||||
def header2module(header):
|
|
||||||
"""header2module(header) -> string
|
|
||||||
|
|
||||||
Convert paths to C++ header files to according Python modules
|
|
||||||
>>> header2module("foo/bar/baz.h")
|
|
||||||
'foo.bar.baz'
|
|
||||||
"""
|
|
||||||
|
|
||||||
if header.endswith(".h"):
|
|
||||||
header = header[:-2]
|
|
||||||
|
|
||||||
mpath = []
|
|
||||||
for part in header.split("/"):
|
|
||||||
# Ignore any empty parts or those that refer to the current
|
|
||||||
# directory.
|
|
||||||
if part not in ("", "."):
|
|
||||||
if part == "..":
|
|
||||||
# We should allow this for Python3.
|
|
||||||
raise SyntaxError(
|
|
||||||
"custom widget header file name may not contain '..'."
|
|
||||||
)
|
|
||||||
|
|
||||||
mpath.append(part)
|
|
||||||
|
|
||||||
return ".".join(mpath)
|
|
||||||
|
|
||||||
for custom_widget in el:
|
|
||||||
classname = custom_widget.findtext("class")
|
|
||||||
header = custom_widget.findtext("header")
|
|
||||||
if header:
|
|
||||||
header = self._translate_bec_widgets_header(header)
|
|
||||||
self.factory.addCustomWidget(
|
|
||||||
classname,
|
|
||||||
custom_widget.findtext("extends") or "QWidget",
|
|
||||||
header2module(header),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _translate_bec_widgets_header(self, header):
|
|
||||||
for name, value in self.custom_widgets.items():
|
|
||||||
if header == DesignerPluginInfo.pascal_to_snake(name):
|
|
||||||
return value.__module__
|
|
||||||
return header
|
|
||||||
|
|
||||||
return CustomDynamicUILoader("", self.custom_widgets).loadUi(ui_file, parent)
|
|
||||||
|
|
||||||
def load_ui(self, ui_file, parent=None):
|
def load_ui(self, ui_file, parent=None):
|
||||||
"""
|
"""
|
||||||
Universal UI loader method.
|
Universal UI loader method.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from qtpy.QtWidgets import (
|
|||||||
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
from bec_widgets.utils import BECConnector
|
from bec_widgets.utils.bec_connector import BECConnector
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
|
|
||||||
@@ -85,7 +85,11 @@ class ComboBoxHandler(WidgetHandler):
|
|||||||
|
|
||||||
def set_value(self, widget: QComboBox, value: int | str) -> None:
|
def set_value(self, widget: QComboBox, value: int | str) -> None:
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
value = widget.findText(value)
|
index = widget.findText(value)
|
||||||
|
if index < 0 and widget.isEditable():
|
||||||
|
widget.setCurrentText(value)
|
||||||
|
return
|
||||||
|
value = index
|
||||||
if isinstance(value, int):
|
if isinstance(value, int):
|
||||||
widget.setCurrentIndex(value)
|
widget.setCurrentIndex(value)
|
||||||
|
|
||||||
@@ -418,7 +422,7 @@ class WidgetHierarchy:
|
|||||||
only_bec_widgets(bool, optional): Whether to print only widgets that are instances of BECWidget.
|
only_bec_widgets(bool, optional): Whether to print only widgets that are instances of BECWidget.
|
||||||
show_parent(bool, optional): Whether to display which BECWidget is the parent of each discovered BECWidget.
|
show_parent(bool, optional): Whether to display which BECWidget is the parent of each discovered BECWidget.
|
||||||
"""
|
"""
|
||||||
from bec_widgets.utils import BECConnector
|
from bec_widgets.utils.bec_connector import BECConnector
|
||||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||||
|
|
||||||
for node in WidgetHierarchy.iter_widget_tree(
|
for node in WidgetHierarchy.iter_widget_tree(
|
||||||
@@ -468,7 +472,7 @@ class WidgetHierarchy:
|
|||||||
|
|
||||||
from qtpy.QtWidgets import QApplication
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
from bec_widgets.utils import BECConnector
|
from bec_widgets.utils.bec_connector import BECConnector
|
||||||
from bec_widgets.widgets.plots.plot_base import PlotBase
|
from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||||
|
|
||||||
# 1) Gather ALL QWidget-based BECConnector objects
|
# 1) Gather ALL QWidget-based BECConnector objects
|
||||||
@@ -534,7 +538,7 @@ class WidgetHierarchy:
|
|||||||
Returns:
|
Returns:
|
||||||
The nearest ancestor that is a BECConnector, or None if not found.
|
The nearest ancestor that is a BECConnector, or None if not found.
|
||||||
"""
|
"""
|
||||||
from bec_widgets.utils import BECConnector
|
from bec_widgets.utils.bec_connector import BECConnector
|
||||||
|
|
||||||
# Guard against deleted/invalid Qt wrappers
|
# Guard against deleted/invalid Qt wrappers
|
||||||
if not shb.isValid(widget):
|
if not shb.isValid(widget):
|
||||||
@@ -636,7 +640,7 @@ class WidgetHierarchy:
|
|||||||
Return all BECConnector instances whose closest BECConnector ancestor is the given widget,
|
Return all BECConnector instances whose closest BECConnector ancestor is the given widget,
|
||||||
including the widget itself if it is a BECConnector.
|
including the widget itself if it is a BECConnector.
|
||||||
"""
|
"""
|
||||||
from bec_widgets.utils import BECConnector
|
from bec_widgets.utils.bec_connector import BECConnector
|
||||||
|
|
||||||
connectors: list[BECConnector] = []
|
connectors: list[BECConnector] = []
|
||||||
if isinstance(widget, BECConnector):
|
if isinstance(widget, BECConnector):
|
||||||
@@ -664,7 +668,7 @@ class WidgetHierarchy:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from bec_widgets.utils import BECConnector # local import to avoid cycles
|
from bec_widgets.utils.bec_connector import BECConnector # local import to avoid cycles
|
||||||
|
|
||||||
is_bec_target = False
|
is_bec_target = False
|
||||||
if isinstance(ancestor_class, str):
|
if isinstance(ancestor_class, str):
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ from shiboken6 import isValid
|
|||||||
|
|
||||||
import bec_widgets.widgets.containers.qt_ads as QtAds
|
import bec_widgets.widgets.containers.qt_ads as QtAds
|
||||||
from bec_widgets import BECWidget, SafeSlot
|
from bec_widgets import BECWidget, SafeSlot
|
||||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
|
||||||
from bec_widgets.utils.bec_connector import BECConnector
|
from bec_widgets.utils.bec_connector import BECConnector
|
||||||
from bec_widgets.utils.property_editor import PropertyEditor
|
from bec_widgets.utils.property_editor import PropertyEditor
|
||||||
|
from bec_widgets.utils.rpc_widget_handler import widget_handler
|
||||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||||
from bec_widgets.widgets.containers.qt_ads import (
|
from bec_widgets.widgets.containers.qt_ads import (
|
||||||
CDockAreaWidget,
|
CDockAreaWidget,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from functools import lru_cache
|
||||||
from typing import Literal, Mapping, Sequence
|
from typing import Literal, Mapping, Sequence
|
||||||
|
|
||||||
import slugify
|
import slugify
|
||||||
@@ -19,11 +20,15 @@ from qtpy.QtWidgets import (
|
|||||||
|
|
||||||
import bec_widgets.widgets.containers.qt_ads as QtAds
|
import bec_widgets.widgets.containers.qt_ads as QtAds
|
||||||
from bec_widgets import BECWidget, SafeProperty, SafeSlot
|
from bec_widgets import BECWidget, SafeProperty, SafeSlot
|
||||||
from bec_widgets.applications.views.view import ViewTourSteps
|
from bec_widgets.cli.designer_plugins import widget_icons
|
||||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
from bec_widgets.utils.bec_plugin_helper import (
|
||||||
from bec_widgets.utils import BECDispatcher
|
get_plugin_rpc_widget_registry,
|
||||||
|
get_plugin_widget_icons,
|
||||||
|
)
|
||||||
from bec_widgets.utils.colors import apply_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
|
from bec_widgets.utils.plugin_utils import get_rpc_widget_registry
|
||||||
from bec_widgets.utils.rpc_decorator import rpc_timeout
|
from bec_widgets.utils.rpc_decorator import rpc_timeout
|
||||||
|
from bec_widgets.utils.rpc_widget_handler import widget_handler
|
||||||
from bec_widgets.utils.toolbars.actions import (
|
from bec_widgets.utils.toolbars.actions import (
|
||||||
ExpandableMenuAction,
|
ExpandableMenuAction,
|
||||||
MaterialIconAction,
|
MaterialIconAction,
|
||||||
@@ -35,25 +40,25 @@ from bec_widgets.utils.widget_state_manager import WidgetStateManager
|
|||||||
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
|
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
|
||||||
from bec_widgets.widgets.containers.dock_area.profile_utils import (
|
from bec_widgets.widgets.containers.dock_area.profile_utils import (
|
||||||
SETTINGS_KEYS,
|
SETTINGS_KEYS,
|
||||||
default_profile_candidates,
|
baseline_profile_candidates,
|
||||||
delete_profile_files,
|
delete_profile_files,
|
||||||
get_last_profile,
|
get_last_profile,
|
||||||
is_profile_read_only,
|
is_profile_read_only,
|
||||||
is_quick_select,
|
is_quick_select,
|
||||||
list_profiles,
|
list_profiles,
|
||||||
list_quick_profiles,
|
list_quick_profiles,
|
||||||
load_default_profile_screenshot,
|
load_baseline_profile_screenshot,
|
||||||
load_user_profile_screenshot,
|
load_runtime_profile_screenshot,
|
||||||
now_iso_utc,
|
now_iso_utc,
|
||||||
open_default_settings,
|
open_baseline_settings,
|
||||||
open_user_settings,
|
open_runtime_settings,
|
||||||
profile_origin,
|
profile_origin,
|
||||||
profile_origin_display,
|
profile_origin_display,
|
||||||
read_manifest,
|
read_manifest,
|
||||||
restore_user_from_default,
|
restore_runtime_from_baseline,
|
||||||
|
runtime_profile_candidates,
|
||||||
set_last_profile,
|
set_last_profile,
|
||||||
set_quick_select,
|
set_quick_select,
|
||||||
user_profile_candidates,
|
|
||||||
write_manifest,
|
write_manifest,
|
||||||
)
|
)
|
||||||
from bec_widgets.widgets.containers.dock_area.settings.dialogs import (
|
from bec_widgets.widgets.containers.dock_area.settings.dialogs import (
|
||||||
@@ -65,22 +70,7 @@ from bec_widgets.widgets.containers.dock_area.toolbar_components.workspace_actio
|
|||||||
WorkspaceConnection,
|
WorkspaceConnection,
|
||||||
workspace_bundle,
|
workspace_bundle,
|
||||||
)
|
)
|
||||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindowNoRPC
|
|
||||||
from bec_widgets.widgets.containers.qt_ads import CDockWidget
|
from bec_widgets.widgets.containers.qt_ads import CDockWidget
|
||||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox, PositionerBox2D
|
|
||||||
from bec_widgets.widgets.control.scan_control import ScanControl
|
|
||||||
from bec_widgets.widgets.editors.bec_console.bec_console import BecConsole, BECShell
|
|
||||||
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
|
|
||||||
from bec_widgets.widgets.plots.image.image import Image
|
|
||||||
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
|
|
||||||
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
|
|
||||||
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
|
|
||||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
|
||||||
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
|
|
||||||
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
|
|
||||||
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
|
|
||||||
from bec_widgets.widgets.utility.logpanel import LogPanel
|
|
||||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
|
|
||||||
@@ -90,6 +80,19 @@ PROFILE_STATE_KEYS = {key: SETTINGS_KEYS[key] for key in ("geom", "state", "ads_
|
|||||||
StartupProfile = Literal["restore", "skip"] | str | None
|
StartupProfile = Literal["restore", "skip"] | str | None
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def _plugin_toolbar_actions() -> dict[str, tuple[str, str, str]]:
|
||||||
|
plugin_registry = get_plugin_rpc_widget_registry()
|
||||||
|
internal_registry = get_rpc_widget_registry()
|
||||||
|
plugin_icons = get_plugin_widget_icons()
|
||||||
|
|
||||||
|
return {
|
||||||
|
widget_name: (plugin_icons.get(widget_name, "widgets"), f"Add {widget_name}", widget_name)
|
||||||
|
for widget_name in sorted(plugin_registry)
|
||||||
|
if widget_name not in internal_registry
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class BECDockArea(DockAreaWidget):
|
class BECDockArea(DockAreaWidget):
|
||||||
RPC = True
|
RPC = True
|
||||||
PLUGIN = False
|
PLUGIN = False
|
||||||
@@ -108,6 +111,7 @@ class BECDockArea(DockAreaWidget):
|
|||||||
"list_profiles",
|
"list_profiles",
|
||||||
"save_profile",
|
"save_profile",
|
||||||
"load_profile",
|
"load_profile",
|
||||||
|
"restore_baseline_profile",
|
||||||
"delete_profile",
|
"delete_profile",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -143,6 +147,10 @@ class BECDockArea(DockAreaWidget):
|
|||||||
self._mode = mode
|
self._mode = mode
|
||||||
|
|
||||||
# Toolbar
|
# Toolbar
|
||||||
|
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import (
|
||||||
|
DarkModeButton,
|
||||||
|
)
|
||||||
|
|
||||||
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
|
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
|
||||||
self.dark_mode_button.setVisible(enable_profile_management)
|
self.dark_mode_button.setVisible(enable_profile_management)
|
||||||
self._setup_toolbar()
|
self._setup_toolbar()
|
||||||
@@ -235,11 +243,8 @@ class BECDockArea(DockAreaWidget):
|
|||||||
def _load_initial_profile(self, name: str) -> None:
|
def _load_initial_profile(self, name: str) -> None:
|
||||||
"""Load the initial profile."""
|
"""Load the initial profile."""
|
||||||
self.load_profile(name)
|
self.load_profile(name)
|
||||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
|
||||||
combo.blockSignals(True)
|
|
||||||
if not self._empty_profile_active:
|
if not self._empty_profile_active:
|
||||||
combo.setCurrentText(name)
|
self._set_workspace_combo_text_silent(name)
|
||||||
combo.blockSignals(False)
|
|
||||||
|
|
||||||
def _start_empty_workspace(self) -> None:
|
def _start_empty_workspace(self) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -344,38 +349,42 @@ class BECDockArea(DockAreaWidget):
|
|||||||
self.toolbar = ModularToolBar(parent=self)
|
self.toolbar = ModularToolBar(parent=self)
|
||||||
|
|
||||||
plot_actions = {
|
plot_actions = {
|
||||||
"waveform": (Waveform.ICON_NAME, "Add Waveform", "Waveform"),
|
"waveform": (widget_icons["Waveform"], "Add Waveform", "Waveform"),
|
||||||
"scatter_waveform": (
|
"scatter_waveform": (
|
||||||
ScatterWaveform.ICON_NAME,
|
widget_icons["ScatterWaveform"],
|
||||||
"Add Scatter Waveform",
|
"Add Scatter Waveform",
|
||||||
"ScatterWaveform",
|
"ScatterWaveform",
|
||||||
),
|
),
|
||||||
"multi_waveform": (MultiWaveform.ICON_NAME, "Add Multi Waveform", "MultiWaveform"),
|
"multi_waveform": (
|
||||||
"image": (Image.ICON_NAME, "Add Image", "Image"),
|
widget_icons["MultiWaveform"],
|
||||||
"motor_map": (MotorMap.ICON_NAME, "Add Motor Map", "MotorMap"),
|
"Add Multi Waveform",
|
||||||
"heatmap": (Heatmap.ICON_NAME, "Add Heatmap", "Heatmap"),
|
"MultiWaveform",
|
||||||
|
),
|
||||||
|
"image": (widget_icons["Image"], "Add Image", "Image"),
|
||||||
|
"motor_map": (widget_icons["MotorMap"], "Add Motor Map", "MotorMap"),
|
||||||
|
"heatmap": (widget_icons["Heatmap"], "Add Heatmap", "Heatmap"),
|
||||||
}
|
}
|
||||||
device_actions = {
|
device_actions = {
|
||||||
"scan_control": (ScanControl.ICON_NAME, "Add Scan Control", "ScanControl"),
|
"scan_control": (widget_icons["ScanControl"], "Add Scan Control", "ScanControl"),
|
||||||
"positioner_box": (PositionerBox.ICON_NAME, "Add Device Box", "PositionerBox"),
|
"positioner_box": (widget_icons["PositionerBox"], "Add Device Box", "PositionerBox"),
|
||||||
"positioner_box_2D": (
|
"positioner_box_2D": (
|
||||||
PositionerBox2D.ICON_NAME,
|
widget_icons["PositionerBox2D"],
|
||||||
"Add Device 2D Box",
|
"Add Device 2D Box",
|
||||||
"PositionerBox2D",
|
"PositionerBox2D",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
util_actions = {
|
util_actions = {
|
||||||
"queue": (BECQueue.ICON_NAME, "Add Scan Queue", "BECQueue"),
|
"queue": (widget_icons["BECQueue"], "Add Scan Queue", "BECQueue"),
|
||||||
"status": (BECStatusBox.ICON_NAME, "Add BEC Status Box", "BECStatusBox"),
|
"status": (widget_icons["BECStatusBox"], "Add BEC Status Box", "BECStatusBox"),
|
||||||
"progress_bar": (
|
"progress_bar": (
|
||||||
RingProgressBar.ICON_NAME,
|
widget_icons["RingProgressBar"],
|
||||||
"Add Circular ProgressBar",
|
"Add Circular ProgressBar",
|
||||||
"RingProgressBar",
|
"RingProgressBar",
|
||||||
),
|
),
|
||||||
"terminal": (BecConsole.ICON_NAME, "Add Terminal", "BecConsole"),
|
"terminal": (widget_icons["BecConsole"], "Add Terminal", "BecConsole"),
|
||||||
"bec_shell": (BECShell.ICON_NAME, "Add BEC Shell", "BECShell"),
|
"bec_shell": (widget_icons["BECShell"], "Add BEC Shell", "BECShell"),
|
||||||
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"),
|
"sbb_monitor": (widget_icons["SBBMonitor"], "Add SBB Monitor", "SBBMonitor"),
|
||||||
"sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"),
|
"log_panel": (widget_icons["LogPanel"], "Add LogPanel", "LogPanel"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create expandable menu actions (original behavior)
|
# Create expandable menu actions (original behavior)
|
||||||
@@ -400,6 +409,10 @@ class BECDockArea(DockAreaWidget):
|
|||||||
_build_menu("menu_devices", "Add Device Control ", device_actions)
|
_build_menu("menu_devices", "Add Device Control ", device_actions)
|
||||||
_build_menu("menu_utils", "Add Utils ", util_actions)
|
_build_menu("menu_utils", "Add Utils ", util_actions)
|
||||||
|
|
||||||
|
plugin_actions = _plugin_toolbar_actions()
|
||||||
|
if plugin_actions:
|
||||||
|
_build_menu("menu_plugins", "Add Plugins ", plugin_actions)
|
||||||
|
|
||||||
# Create flat toolbar bundles for each widget type
|
# Create flat toolbar bundles for each widget type
|
||||||
def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]):
|
def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]):
|
||||||
bundle = ToolbarBundle(f"flat_{category}", self.toolbar.components)
|
bundle = ToolbarBundle(f"flat_{category}", self.toolbar.components)
|
||||||
@@ -470,14 +483,16 @@ class BECDockArea(DockAreaWidget):
|
|||||||
bda.add_action("dark_mode")
|
bda.add_action("dark_mode")
|
||||||
self.toolbar.add_bundle(bda)
|
self.toolbar.add_bundle(bda)
|
||||||
|
|
||||||
self._apply_toolbar_layout()
|
# Store mappings on self for use in _hook_toolbar and _apply_toolbar_layout
|
||||||
|
|
||||||
# Store mappings on self for use in _hook_toolbar
|
|
||||||
self._ACTION_MAPPINGS = {
|
self._ACTION_MAPPINGS = {
|
||||||
"menu_plots": plot_actions,
|
"menu_plots": plot_actions,
|
||||||
"menu_devices": device_actions,
|
"menu_devices": device_actions,
|
||||||
"menu_utils": util_actions,
|
"menu_utils": util_actions,
|
||||||
}
|
}
|
||||||
|
if plugin_actions:
|
||||||
|
self._ACTION_MAPPINGS["menu_plugins"] = plugin_actions
|
||||||
|
|
||||||
|
self._apply_toolbar_layout()
|
||||||
|
|
||||||
def _hook_toolbar(self):
|
def _hook_toolbar(self):
|
||||||
def _connect_menu(menu_key: str):
|
def _connect_menu(menu_key: str):
|
||||||
@@ -486,10 +501,9 @@ class BECDockArea(DockAreaWidget):
|
|||||||
|
|
||||||
# first two items not needed for this part
|
# first two items not needed for this part
|
||||||
for key, (_, _, widget_type) in mapping.items():
|
for key, (_, _, widget_type) in mapping.items():
|
||||||
act = menu.actions[key].action
|
toolbar_action = menu.actions[key]
|
||||||
if widget_type == "LogPanel":
|
act = toolbar_action.action
|
||||||
act.setEnabled(False) # keep disabled per issue #644
|
if key == "terminal":
|
||||||
elif key == "terminal":
|
|
||||||
act.triggered.connect(
|
act.triggered.connect(
|
||||||
lambda _, t=widget_type: self.new(widget=t, closable=True, startup_cmd=None)
|
lambda _, t=widget_type: self.new(widget=t, closable=True, startup_cmd=None)
|
||||||
)
|
)
|
||||||
@@ -499,21 +513,24 @@ class BECDockArea(DockAreaWidget):
|
|||||||
widget=t, closable=True, show_settings_action=False
|
widget=t, closable=True, show_settings_action=False
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
elif menu_key == "menu_plugins":
|
||||||
|
act.triggered.connect(
|
||||||
|
lambda _, t=widget_type, a=toolbar_action: self._new_plugin_widget(t, a)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
act.triggered.connect(lambda _, t=widget_type: self.new(widget=t))
|
act.triggered.connect(lambda _, t=widget_type: self.new(widget=t))
|
||||||
|
|
||||||
_connect_menu("menu_plots")
|
_connect_menu("menu_plots")
|
||||||
_connect_menu("menu_devices")
|
_connect_menu("menu_devices")
|
||||||
_connect_menu("menu_utils")
|
_connect_menu("menu_utils")
|
||||||
|
if "menu_plugins" in self._ACTION_MAPPINGS:
|
||||||
|
_connect_menu("menu_plugins")
|
||||||
|
|
||||||
def _connect_flat_actions(mapping: dict[str, tuple[str, str, str]]):
|
def _connect_flat_actions(mapping: dict[str, tuple[str, str, str]]):
|
||||||
for action_id, (_, _, widget_type) in mapping.items():
|
for action_id, (_, _, widget_type) in mapping.items():
|
||||||
flat_action_id = f"flat_{action_id}"
|
flat_action_id = f"flat_{action_id}"
|
||||||
flat_action = self.toolbar.components.get_action(flat_action_id).action
|
flat_action = self.toolbar.components.get_action(flat_action_id).action
|
||||||
if widget_type == "LogPanel":
|
flat_action.triggered.connect(lambda _, t=widget_type: self.new(t))
|
||||||
flat_action.setEnabled(False) # keep disabled per issue #644
|
|
||||||
else:
|
|
||||||
flat_action.triggered.connect(lambda _, t=widget_type: self.new(t))
|
|
||||||
|
|
||||||
_connect_flat_actions(self._ACTION_MAPPINGS["menu_plots"])
|
_connect_flat_actions(self._ACTION_MAPPINGS["menu_plots"])
|
||||||
_connect_flat_actions(self._ACTION_MAPPINGS["menu_devices"])
|
_connect_flat_actions(self._ACTION_MAPPINGS["menu_devices"])
|
||||||
@@ -522,6 +539,10 @@ class BECDockArea(DockAreaWidget):
|
|||||||
self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all)
|
self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all)
|
||||||
self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot)
|
self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot)
|
||||||
|
|
||||||
|
def _new_plugin_widget(self, widget_type: str, toolbar_action: MaterialIconAction) -> None:
|
||||||
|
# Created as helper method for simple tests
|
||||||
|
self.new(widget=widget_type, dock_icon=toolbar_action.get_icon())
|
||||||
|
|
||||||
def _set_editable(self, editable: bool) -> None:
|
def _set_editable(self, editable: bool) -> None:
|
||||||
self.workspace_is_locked = not editable
|
self.workspace_is_locked = not editable
|
||||||
self._editable = editable
|
self._editable = editable
|
||||||
@@ -595,13 +616,13 @@ class BECDockArea(DockAreaWidget):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def profile_namespace(self) -> str | None:
|
def profile_namespace(self) -> str | None:
|
||||||
"""Namespace used to scope user/default profile files for this dock area."""
|
"""Namespace used to scope runtime/baseline profile files for this dock area."""
|
||||||
return self._resolve_profile_namespace()
|
return self._resolve_profile_namespace()
|
||||||
|
|
||||||
def _profile_exists(self, name: str, namespace: str | None) -> bool:
|
def _profile_exists(self, name: str, namespace: str | None) -> bool:
|
||||||
return any(
|
return any(
|
||||||
os.path.exists(path) for path in user_profile_candidates(name, namespace)
|
os.path.exists(path) for path in runtime_profile_candidates(name, namespace)
|
||||||
) or any(os.path.exists(path) for path in default_profile_candidates(name, namespace))
|
) or any(os.path.exists(path) for path in baseline_profile_candidates(name, namespace))
|
||||||
|
|
||||||
def _write_snapshot_to_settings(self, settings, save_preview: bool = True) -> None:
|
def _write_snapshot_to_settings(self, settings, save_preview: bool = True) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -627,35 +648,34 @@ class BECDockArea(DockAreaWidget):
|
|||||||
name: str,
|
name: str,
|
||||||
namespace: str | None,
|
namespace: str | None,
|
||||||
*,
|
*,
|
||||||
write_default: bool = True,
|
write_baseline: bool = True,
|
||||||
write_user: bool = True,
|
write_runtime: bool = True,
|
||||||
save_preview: bool = True,
|
save_preview: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Write profile settings to default and/or user settings files.
|
Write profile settings to baseline and/or runtime settings files.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: The profile name.
|
name: The profile name.
|
||||||
namespace: The profile namespace.
|
namespace: The profile namespace.
|
||||||
write_default: Whether to write to the default settings file.
|
write_baseline: Whether to write to the baseline settings file.
|
||||||
write_user: Whether to write to the user settings file.
|
write_runtime: Whether to write to the runtime settings file.
|
||||||
save_preview: Whether to save a screenshot preview.
|
save_preview: Whether to save a screenshot preview.
|
||||||
"""
|
"""
|
||||||
if write_default:
|
|
||||||
ds = open_default_settings(name, namespace=namespace)
|
|
||||||
self._write_snapshot_to_settings(ds, save_preview=save_preview)
|
|
||||||
if not ds.value(SETTINGS_KEYS["created_at"], ""):
|
|
||||||
ds.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
|
|
||||||
if not ds.value(SETTINGS_KEYS["is_quick_select"], None):
|
|
||||||
ds.setValue(SETTINGS_KEYS["is_quick_select"], True)
|
|
||||||
|
|
||||||
if write_user:
|
def _write_settings(open_settings) -> None:
|
||||||
us = open_user_settings(name, namespace=namespace)
|
settings = open_settings(name, namespace=namespace)
|
||||||
self._write_snapshot_to_settings(us, save_preview=save_preview)
|
self._write_snapshot_to_settings(settings, save_preview=save_preview)
|
||||||
if not us.value(SETTINGS_KEYS["created_at"], ""):
|
if not settings.value(SETTINGS_KEYS["created_at"], ""):
|
||||||
us.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
|
settings.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
|
||||||
if not us.value(SETTINGS_KEYS["is_quick_select"], None):
|
if not settings.value(SETTINGS_KEYS["is_quick_select"], None):
|
||||||
us.setValue(SETTINGS_KEYS["is_quick_select"], True)
|
settings.setValue(SETTINGS_KEYS["is_quick_select"], True)
|
||||||
|
|
||||||
|
if write_baseline:
|
||||||
|
_write_settings(open_baseline_settings)
|
||||||
|
|
||||||
|
if write_runtime:
|
||||||
|
_write_settings(open_runtime_settings)
|
||||||
|
|
||||||
def _finalize_profile_change(self, name: str, namespace: str | None) -> None:
|
def _finalize_profile_change(self, name: str, namespace: str | None) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -673,6 +693,14 @@ class BECDockArea(DockAreaWidget):
|
|||||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||||
combo.refresh_profiles(active_profile=name)
|
combo.refresh_profiles(active_profile=name)
|
||||||
|
|
||||||
|
def _set_workspace_combo_text_silent(self, text: str) -> None:
|
||||||
|
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||||
|
was_blocked = combo.blockSignals(True)
|
||||||
|
try:
|
||||||
|
combo.setCurrentText(text)
|
||||||
|
finally:
|
||||||
|
combo.blockSignals(was_blocked)
|
||||||
|
|
||||||
def _enter_empty_profile_state(self) -> None:
|
def _enter_empty_profile_state(self) -> None:
|
||||||
"""
|
"""
|
||||||
Switch to the transient empty workspace state.
|
Switch to the transient empty workspace state.
|
||||||
@@ -709,10 +737,10 @@ class BECDockArea(DockAreaWidget):
|
|||||||
Save the current workspace profile.
|
Save the current workspace profile.
|
||||||
|
|
||||||
On first save of a given name:
|
On first save of a given name:
|
||||||
- writes a default copy to states/default/<name>.ini with tag=default and created_at
|
- writes a baseline copy to profiles/baseline/<name>.ini with created_at
|
||||||
- writes a user copy to states/user/<name>.ini with tag=user and created_at
|
- writes a runtime copy to profiles/runtime/<name>.ini with created_at
|
||||||
On subsequent saves of user-owned profiles:
|
On subsequent saves:
|
||||||
- updates both the default and user copies so restore uses the latest snapshot.
|
- updates both the baseline and runtime copies so restore uses the latest snapshot.
|
||||||
Read-only bundled profiles cannot be overwritten.
|
Read-only bundled profiles cannot be overwritten.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -776,7 +804,7 @@ class BECDockArea(DockAreaWidget):
|
|||||||
overwrite_existing = origin == "settings"
|
overwrite_existing = origin == "settings"
|
||||||
|
|
||||||
origin_before_save = profile_origin(name, namespace=namespace)
|
origin_before_save = profile_origin(name, namespace=namespace)
|
||||||
overwrite_default = overwrite_existing and origin_before_save == "settings"
|
overwrite_baseline = overwrite_existing and origin_before_save == "settings"
|
||||||
|
|
||||||
# Display saving placeholder in toolbar
|
# Display saving placeholder in toolbar
|
||||||
workspace_combo = self.toolbar.components.get_action("workspace_combo").widget
|
workspace_combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||||
@@ -785,12 +813,12 @@ class BECDockArea(DockAreaWidget):
|
|||||||
workspace_combo.setCurrentIndex(0)
|
workspace_combo.setCurrentIndex(0)
|
||||||
workspace_combo.blockSignals(False)
|
workspace_combo.blockSignals(False)
|
||||||
|
|
||||||
# Write to default and/or user settings
|
# Write to baseline and/or runtime settings
|
||||||
should_write_default = overwrite_default or not any(
|
should_write_baseline = overwrite_baseline or not any(
|
||||||
os.path.exists(path) for path in default_profile_candidates(name, namespace)
|
os.path.exists(path) for path in baseline_profile_candidates(name, namespace)
|
||||||
)
|
)
|
||||||
self._write_profile_settings(
|
self._write_profile_settings(
|
||||||
name, namespace, write_default=should_write_default, write_user=True
|
name, namespace, write_baseline=should_write_baseline, write_runtime=True
|
||||||
)
|
)
|
||||||
|
|
||||||
set_quick_select(name, quickselect, namespace=namespace)
|
set_quick_select(name, quickselect, namespace=namespace)
|
||||||
@@ -800,7 +828,6 @@ class BECDockArea(DockAreaWidget):
|
|||||||
self._pending_autosave_skip = (current_profile, name)
|
self._pending_autosave_skip = (current_profile, name)
|
||||||
else:
|
else:
|
||||||
self._pending_autosave_skip = None
|
self._pending_autosave_skip = None
|
||||||
workspace_combo.setCurrentText(name)
|
|
||||||
self._finalize_profile_change(name, namespace)
|
self._finalize_profile_change(name, namespace)
|
||||||
|
|
||||||
@SafeSlot()
|
@SafeSlot()
|
||||||
@@ -820,16 +847,21 @@ class BECDockArea(DockAreaWidget):
|
|||||||
|
|
||||||
@SafeSlot()
|
@SafeSlot()
|
||||||
@SafeSlot(str)
|
@SafeSlot(str)
|
||||||
|
@SafeSlot(str, bool)
|
||||||
@rpc_timeout(None)
|
@rpc_timeout(None)
|
||||||
def load_profile(self, name: str | None = None):
|
def load_profile(self, name: str | None = None, restore_baseline: bool = False):
|
||||||
"""
|
"""
|
||||||
Load a workspace profile.
|
Load a workspace profile.
|
||||||
|
|
||||||
Before switching, persist the current profile to the user copy.
|
Before switching, persist the current profile to the runtime copy.
|
||||||
Prefer loading the user copy; fall back to the default copy.
|
Prefer loading the runtime copy; fall back to the baseline copy. When
|
||||||
|
``restore_baseline`` is True, first overwrite the runtime copy with the
|
||||||
|
baseline profile and then load it.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str | None): The name of the profile to load. If None, prompts the user.
|
name (str | None): The name of the profile to load. If None, prompts the user.
|
||||||
|
restore_baseline (bool): If True, restore the runtime copy from the
|
||||||
|
baseline before loading. Defaults to False.
|
||||||
"""
|
"""
|
||||||
if name == "":
|
if name == "":
|
||||||
return
|
return
|
||||||
@@ -848,14 +880,17 @@ class BECDockArea(DockAreaWidget):
|
|||||||
if skip_pair and skip_pair == (prev_name, name):
|
if skip_pair and skip_pair == (prev_name, name):
|
||||||
self._pending_autosave_skip = None
|
self._pending_autosave_skip = None
|
||||||
else:
|
else:
|
||||||
us_prev = open_user_settings(prev_name, namespace=namespace)
|
us_prev = open_runtime_settings(prev_name, namespace=namespace)
|
||||||
self._write_snapshot_to_settings(us_prev, save_preview=True)
|
self._write_snapshot_to_settings(us_prev, save_preview=True)
|
||||||
|
|
||||||
|
if restore_baseline:
|
||||||
|
restore_runtime_from_baseline(name, namespace=namespace)
|
||||||
|
|
||||||
settings = None
|
settings = None
|
||||||
if any(os.path.exists(path) for path in user_profile_candidates(name, namespace)):
|
if any(os.path.exists(path) for path in runtime_profile_candidates(name, namespace)):
|
||||||
settings = open_user_settings(name, namespace=namespace)
|
settings = open_runtime_settings(name, namespace=namespace)
|
||||||
elif any(os.path.exists(path) for path in default_profile_candidates(name, namespace)):
|
elif any(os.path.exists(path) for path in baseline_profile_candidates(name, namespace)):
|
||||||
settings = open_default_settings(name, namespace=namespace)
|
settings = open_baseline_settings(name, namespace=namespace)
|
||||||
if settings is None:
|
if settings is None:
|
||||||
logger.warning(f"Profile '{name}' not found in namespace '{namespace}'. Creating new.")
|
logger.warning(f"Profile '{name}' not found in namespace '{namespace}'. Creating new.")
|
||||||
self.delete_all()
|
self.delete_all()
|
||||||
@@ -897,32 +932,36 @@ class BECDockArea(DockAreaWidget):
|
|||||||
|
|
||||||
@SafeSlot()
|
@SafeSlot()
|
||||||
@SafeSlot(str)
|
@SafeSlot(str)
|
||||||
def restore_user_profile_from_default(self, name: str | None = None):
|
@SafeSlot(str, bool)
|
||||||
|
@rpc_timeout(None)
|
||||||
|
def restore_baseline_profile(self, name: str | None = None, show_dialog: bool = False):
|
||||||
"""
|
"""
|
||||||
Overwrite the user copy of *name* with the default baseline.
|
Overwrite the runtime copy of *name* with the baseline.
|
||||||
If *name* is None, target the currently active profile.
|
If *name* is None, target the currently active profile.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str | None): The name of the profile to restore. If None, uses the current profile.
|
name (str | None): The name of the profile to restore. If None, uses the current profile.
|
||||||
|
show_dialog (bool): If True, ask for confirmation before restoring.
|
||||||
"""
|
"""
|
||||||
target = name or getattr(self, "_current_profile_name", None)
|
target = name or getattr(self, "_current_profile_name", None)
|
||||||
if not target:
|
if not target:
|
||||||
return
|
return
|
||||||
namespace = self.profile_namespace
|
namespace = self.profile_namespace
|
||||||
|
|
||||||
current_pixmap = None
|
if show_dialog:
|
||||||
if self.isVisible():
|
current_pixmap = None
|
||||||
current_pixmap = QPixmap()
|
if self.isVisible():
|
||||||
ba = bytes(self.screenshot_bytes())
|
current_pixmap = QPixmap()
|
||||||
current_pixmap.loadFromData(ba)
|
ba = bytes(self.screenshot_bytes())
|
||||||
if current_pixmap is None or current_pixmap.isNull():
|
current_pixmap.loadFromData(ba)
|
||||||
current_pixmap = load_user_profile_screenshot(target, namespace=namespace)
|
if current_pixmap is None or current_pixmap.isNull():
|
||||||
default_pixmap = load_default_profile_screenshot(target, namespace=namespace)
|
current_pixmap = load_runtime_profile_screenshot(target, namespace=namespace)
|
||||||
|
baseline_pixmap = load_baseline_profile_screenshot(target, namespace=namespace)
|
||||||
|
|
||||||
if not RestoreProfileDialog.confirm(self, current_pixmap, default_pixmap):
|
if not RestoreProfileDialog.confirm(self, current_pixmap, baseline_pixmap):
|
||||||
return
|
return
|
||||||
|
|
||||||
restore_user_from_default(target, namespace=namespace)
|
restore_runtime_from_baseline(target, namespace=namespace)
|
||||||
self.delete_all()
|
self.delete_all()
|
||||||
self.load_profile(target)
|
self.load_profile(target)
|
||||||
|
|
||||||
@@ -1057,7 +1096,7 @@ class BECDockArea(DockAreaWidget):
|
|||||||
manage_action = self.toolbar.components.get_action("manage_workspaces").action
|
manage_action = self.toolbar.components.get_action("manage_workspaces").action
|
||||||
if self.manage_dialog is None or not self.manage_dialog.isVisible():
|
if self.manage_dialog is None or not self.manage_dialog.isVisible():
|
||||||
self.manage_widget = WorkSpaceManager(
|
self.manage_widget = WorkSpaceManager(
|
||||||
self, target_widget=self, default_profile=self._current_profile_name
|
self, target_widget=self, active_profile=self._current_profile_name
|
||||||
)
|
)
|
||||||
self.manage_dialog = QDialog(modal=False)
|
self.manage_dialog = QDialog(modal=False)
|
||||||
|
|
||||||
@@ -1105,14 +1144,10 @@ class BECDockArea(DockAreaWidget):
|
|||||||
if mode_key == "user":
|
if mode_key == "user":
|
||||||
bundles = ["spacer_bundle", "workspace", "dock_actions"]
|
bundles = ["spacer_bundle", "workspace", "dock_actions"]
|
||||||
elif mode_key == "creator":
|
elif mode_key == "creator":
|
||||||
bundles = [
|
bundles = ["menu_plots", "menu_devices", "menu_utils"]
|
||||||
"menu_plots",
|
if "menu_plugins" in getattr(self, "_ACTION_MAPPINGS", {}):
|
||||||
"menu_devices",
|
bundles.append("menu_plugins")
|
||||||
"menu_utils",
|
bundles += ["spacer_bundle", "workspace", "dock_actions"]
|
||||||
"spacer_bundle",
|
|
||||||
"workspace",
|
|
||||||
"dock_actions",
|
|
||||||
]
|
|
||||||
elif mode_key == "plot":
|
elif mode_key == "plot":
|
||||||
bundles = ["flat_plots", "spacer_bundle", "workspace", "dock_actions"]
|
bundles = ["flat_plots", "spacer_bundle", "workspace", "dock_actions"]
|
||||||
elif mode_key == "device":
|
elif mode_key == "device":
|
||||||
@@ -1156,7 +1191,7 @@ class BECDockArea(DockAreaWidget):
|
|||||||
return
|
return
|
||||||
|
|
||||||
namespace = self.profile_namespace
|
namespace = self.profile_namespace
|
||||||
settings = open_user_settings(name, namespace=namespace)
|
settings = open_runtime_settings(name, namespace=namespace)
|
||||||
self._write_snapshot_to_settings(settings)
|
self._write_snapshot_to_settings(settings)
|
||||||
set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id())
|
set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id())
|
||||||
self._exit_snapshot_written = True
|
self._exit_snapshot_written = True
|
||||||
@@ -1186,6 +1221,8 @@ class BECDockArea(DockAreaWidget):
|
|||||||
)
|
)
|
||||||
step_ids.append(step_id)
|
step_ids.append(step_id)
|
||||||
|
|
||||||
|
from bec_widgets.applications.views.view import ViewTourSteps
|
||||||
|
|
||||||
return ViewTourSteps(view_title="Dock Area Workspace", step_ids=step_ids)
|
return ViewTourSteps(view_title="Dock Area Workspace", step_ids=step_ids)
|
||||||
|
|
||||||
def cleanup(self):
|
def cleanup(self):
|
||||||
@@ -1206,6 +1243,9 @@ class BECDockArea(DockAreaWidget):
|
|||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||||
|
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindowNoRPC
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
apply_theme("dark")
|
apply_theme("dark")
|
||||||
dispatcher = BECDispatcher(gui_id="ads")
|
dispatcher = BECDispatcher(gui_id="ads")
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
Utilities for managing BECDockArea profiles stored in INI files.
|
Utilities for managing BECDockArea profiles stored in INI files.
|
||||||
|
|
||||||
Policy:
|
Policy:
|
||||||
- All created/modified profiles are stored under the BEC settings root: <base_path>/profiles/{default,user}
|
- All created/modified profiles are stored under the BEC settings root:
|
||||||
- Bundled read-only defaults are discovered in BW core states/default and plugin bec_widgets/profiles but never written to.
|
<base_path>/profiles/{baseline,runtime}
|
||||||
- Lookup order when reading: user → settings default → app or plugin bundled default.
|
- Bundled read-only baselines are discovered in BW core profiles and plugin
|
||||||
|
bec_widgets/profiles but never written to.
|
||||||
|
- Lookup order when reading: runtime → settings baseline → app or plugin bundled baseline.
|
||||||
|
- Legacy settings paths profiles/{default,user} are read through a thin segment
|
||||||
|
alias layer and copied to the canonical location on first access.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -32,6 +36,12 @@ logger = bec_logger.logger
|
|||||||
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
ProfileOrigin = Literal["module", "plugin", "settings", "unknown"]
|
ProfileOrigin = Literal["module", "plugin", "settings", "unknown"]
|
||||||
|
ProfileSegment = Literal["baseline", "runtime"]
|
||||||
|
|
||||||
|
_PROFILE_SEGMENT_ALIASES: dict[ProfileSegment, tuple[str, str]] = {
|
||||||
|
"baseline": ("baseline", "default"),
|
||||||
|
"runtime": ("runtime", "user"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def module_profiles_dir() -> str:
|
def module_profiles_dir() -> str:
|
||||||
@@ -130,7 +140,7 @@ def _profiles_dir(segment: str, namespace: str | None) -> str:
|
|||||||
Build (and ensure) the directory that holds profiles for a namespace segment.
|
Build (and ensure) the directory that holds profiles for a namespace segment.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
segment (str): Either ``"user"`` or ``"default"``.
|
segment (str): Profile segment directory name.
|
||||||
namespace (str | None): Optional namespace label to scope profiles.
|
namespace (str | None): Optional namespace label to scope profiles.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -143,157 +153,175 @@ def _profiles_dir(segment: str, namespace: str | None) -> str:
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def _user_path_candidates(name: str, namespace: str | None) -> list[str]:
|
def _candidate_namespaces(namespace: str | None) -> list[str | None]:
|
||||||
"""
|
|
||||||
Generate candidate user-profile paths honoring namespace fallbacks.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name (str): Profile name without extension.
|
|
||||||
namespace (str | None): Optional namespace label.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[str]: Ordered list of candidate user profile paths (.ini files).
|
|
||||||
"""
|
|
||||||
ns = slugify.slugify(namespace, separator="_") if namespace else None
|
ns = slugify.slugify(namespace, separator="_") if namespace else None
|
||||||
primary = os.path.join(_profiles_dir("user", ns), f"{name}.ini")
|
|
||||||
if not ns:
|
if not ns:
|
||||||
return [primary]
|
return [None]
|
||||||
legacy = os.path.join(_profiles_dir("user", None), f"{name}.ini")
|
return [ns, None]
|
||||||
return [primary, legacy] if legacy != primary else [primary]
|
|
||||||
|
|
||||||
|
|
||||||
def _default_path_candidates(name: str, namespace: str | None) -> list[str]:
|
def _segment_profile_path(segment_name: str, name: str, namespace: str | None) -> str:
|
||||||
|
return os.path.join(_profiles_dir(segment_name, namespace), f"{name}.ini")
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_profile_path(segment: ProfileSegment, name: str, namespace: str | None) -> str:
|
||||||
|
return _segment_profile_path(_PROFILE_SEGMENT_ALIASES[segment][0], name, namespace)
|
||||||
|
|
||||||
|
|
||||||
|
def _segment_path_candidates(
|
||||||
|
segment: ProfileSegment,
|
||||||
|
name: str,
|
||||||
|
namespace: str | None,
|
||||||
|
*,
|
||||||
|
include_legacy: bool = True,
|
||||||
|
migrate_legacy: bool = True,
|
||||||
|
) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Generate candidate default-profile paths honoring namespace fallbacks.
|
Generate profile candidates for a canonical segment.
|
||||||
|
|
||||||
Args:
|
Canonical baseline/runtime files are always preferred. Namespace fallback
|
||||||
name (str): Profile name without extension.
|
files and legacy default/user files are copied to the primary canonical path
|
||||||
namespace (str | None): Optional namespace label.
|
when the primary file does not exist.
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[str]: Ordered list of candidate default profile paths (.ini files).
|
|
||||||
"""
|
"""
|
||||||
ns = slugify.slugify(namespace, separator="_") if namespace else None
|
canonical = [
|
||||||
primary = os.path.join(_profiles_dir("default", ns), f"{name}.ini")
|
_segment_profile_path(_PROFILE_SEGMENT_ALIASES[segment][0], name, ns)
|
||||||
if not ns:
|
for ns in _candidate_namespaces(namespace)
|
||||||
return [primary]
|
]
|
||||||
legacy = os.path.join(_profiles_dir("default", None), f"{name}.ini")
|
legacy = []
|
||||||
return [primary, legacy] if legacy != primary else [primary]
|
if include_legacy:
|
||||||
|
legacy = [
|
||||||
|
_segment_profile_path(_PROFILE_SEGMENT_ALIASES[segment][1], name, ns)
|
||||||
|
for ns in _candidate_namespaces(namespace)
|
||||||
|
]
|
||||||
|
|
||||||
|
primary_canonical = canonical[0]
|
||||||
|
if migrate_legacy and not os.path.exists(primary_canonical):
|
||||||
|
canonical_src = next((path for path in canonical[1:] if os.path.exists(path)), None)
|
||||||
|
if canonical_src:
|
||||||
|
os.makedirs(os.path.dirname(primary_canonical), exist_ok=True)
|
||||||
|
shutil.copy2(canonical_src, primary_canonical)
|
||||||
|
elif include_legacy:
|
||||||
|
legacy_src = next((path for path in legacy if os.path.exists(path)), None)
|
||||||
|
if legacy_src:
|
||||||
|
os.makedirs(os.path.dirname(primary_canonical), exist_ok=True)
|
||||||
|
shutil.copy2(legacy_src, primary_canonical)
|
||||||
|
|
||||||
|
return list(dict.fromkeys(canonical + legacy))
|
||||||
|
|
||||||
|
|
||||||
def default_profiles_dir(namespace: str | None = None) -> str:
|
def baseline_profiles_dir(namespace: str | None = None) -> str:
|
||||||
"""
|
"""
|
||||||
Return the directory that stores default profiles for the namespace.
|
Return the directory that stores baseline profiles for the namespace.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Absolute path to the default profile directory.
|
str: Absolute path to the baseline profile directory.
|
||||||
"""
|
"""
|
||||||
return _profiles_dir("default", namespace)
|
return _profiles_dir("baseline", namespace)
|
||||||
|
|
||||||
|
|
||||||
def user_profiles_dir(namespace: str | None = None) -> str:
|
def runtime_profiles_dir(namespace: str | None = None) -> str:
|
||||||
"""
|
"""
|
||||||
Return the directory that stores user profiles for the namespace.
|
Return the directory that stores runtime profiles for the namespace.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Absolute path to the user profile directory.
|
str: Absolute path to the runtime profile directory.
|
||||||
"""
|
"""
|
||||||
return _profiles_dir("user", namespace)
|
return _profiles_dir("runtime", namespace)
|
||||||
|
|
||||||
|
|
||||||
def default_profile_path(name: str, namespace: str | None = None) -> str:
|
def baseline_profile_path(name: str, namespace: str | None = None) -> str:
|
||||||
"""
|
"""
|
||||||
Compute the canonical default profile path for a profile name.
|
Compute the canonical baseline profile path for a profile name.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): Profile name without extension.
|
name (str): Profile name without extension.
|
||||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Absolute path to the default profile file (.ini).
|
str: Absolute path to the baseline profile file (.ini).
|
||||||
"""
|
"""
|
||||||
return _default_path_candidates(name, namespace)[0]
|
return _canonical_profile_path("baseline", name, namespace)
|
||||||
|
|
||||||
|
|
||||||
def user_profile_path(name: str, namespace: str | None = None) -> str:
|
def runtime_profile_path(name: str, namespace: str | None = None) -> str:
|
||||||
"""
|
"""
|
||||||
Compute the canonical user profile path for a profile name.
|
Compute the canonical runtime profile path for a profile name.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): Profile name without extension.
|
name (str): Profile name without extension.
|
||||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Absolute path to the user profile file (.ini).
|
str: Absolute path to the runtime profile file (.ini).
|
||||||
"""
|
"""
|
||||||
return _user_path_candidates(name, namespace)[0]
|
return _canonical_profile_path("runtime", name, namespace)
|
||||||
|
|
||||||
|
|
||||||
def user_profile_candidates(name: str, namespace: str | None = None) -> list[str]:
|
def runtime_profile_candidates(name: str, namespace: str | None = None) -> list[str]:
|
||||||
"""
|
"""
|
||||||
List all user profile path candidates for a profile name.
|
List all runtime profile path candidates for a profile name.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): Profile name without extension.
|
name (str): Profile name without extension.
|
||||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[str]: De-duplicated list of candidate user profile paths.
|
list[str]: De-duplicated list of candidate runtime profile paths.
|
||||||
"""
|
"""
|
||||||
return list(dict.fromkeys(_user_path_candidates(name, namespace)))
|
return _segment_path_candidates("runtime", name, namespace)
|
||||||
|
|
||||||
|
|
||||||
def default_profile_candidates(name: str, namespace: str | None = None) -> list[str]:
|
def baseline_profile_candidates(name: str, namespace: str | None = None) -> list[str]:
|
||||||
"""
|
"""
|
||||||
List all default profile path candidates for a profile name.
|
List all baseline profile path candidates for a profile name.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): Profile name without extension.
|
name (str): Profile name without extension.
|
||||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[str]: De-duplicated list of candidate default profile paths.
|
list[str]: De-duplicated list of candidate baseline profile paths.
|
||||||
"""
|
"""
|
||||||
return list(dict.fromkeys(_default_path_candidates(name, namespace)))
|
return _segment_path_candidates("baseline", name, namespace)
|
||||||
|
|
||||||
|
|
||||||
def _existing_user_settings(name: str, namespace: str | None = None) -> QSettings | None:
|
def _existing_runtime_settings(name: str, namespace: str | None = None) -> QSettings | None:
|
||||||
"""
|
"""
|
||||||
Resolve the first existing user profile settings object.
|
Resolve the first existing runtime profile settings object.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): Profile name without extension.
|
name (str): Profile name without extension.
|
||||||
namespace (str | None, optional): Namespace label to search. Defaults to ``None``.
|
namespace (str | None, optional): Namespace label to search. Defaults to ``None``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
QSettings | None: Config for the first existing user profile candidate, or ``None``
|
QSettings | None: Config for the first existing runtime profile candidate, or ``None``
|
||||||
when no files are present.
|
when no files are present.
|
||||||
"""
|
"""
|
||||||
for path in user_profile_candidates(name, namespace):
|
for path in runtime_profile_candidates(name, namespace):
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
return QSettings(path, QSettings.IniFormat)
|
return QSettings(path, QSettings.IniFormat)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _existing_default_settings(name: str, namespace: str | None = None) -> QSettings | None:
|
def _existing_baseline_settings(name: str, namespace: str | None = None) -> QSettings | None:
|
||||||
"""
|
"""
|
||||||
Resolve the first existing default profile settings object.
|
Resolve the first existing baseline profile settings object.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): Profile name without extension.
|
name (str): Profile name without extension.
|
||||||
namespace (str | None, optional): Namespace label to search. Defaults to ``None``.
|
namespace (str | None, optional): Namespace label to search. Defaults to ``None``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
QSettings | None: Config for the first existing default profile candidate, or ``None``
|
QSettings | None: Config for the first existing baseline profile candidate, or ``None``
|
||||||
when no files are present.
|
when no files are present.
|
||||||
"""
|
"""
|
||||||
for path in default_profile_candidates(name, namespace):
|
for path in baseline_profile_candidates(name, namespace):
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
return QSettings(path, QSettings.IniFormat)
|
return QSettings(path, QSettings.IniFormat)
|
||||||
return None
|
return None
|
||||||
@@ -347,7 +375,7 @@ def profile_origin(name: str, namespace: str | None = None) -> ProfileOrigin:
|
|||||||
plugin_path = plugin_profile_path(name)
|
plugin_path = plugin_profile_path(name)
|
||||||
if plugin_path and os.path.exists(plugin_path):
|
if plugin_path and os.path.exists(plugin_path):
|
||||||
return "plugin"
|
return "plugin"
|
||||||
for path in user_profile_candidates(name, namespace) + default_profile_candidates(
|
for path in runtime_profile_candidates(name, namespace) + baseline_profile_candidates(
|
||||||
name, namespace
|
name, namespace
|
||||||
):
|
):
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
@@ -406,8 +434,8 @@ def delete_profile_files(name: str, namespace: str | None = None) -> bool:
|
|||||||
read_only = is_profile_read_only(name, namespace)
|
read_only = is_profile_read_only(name, namespace)
|
||||||
|
|
||||||
removed = False
|
removed = False
|
||||||
# Always allow removing user copies; keep default copies for read-only origins.
|
# Always allow removing runtime copies; keep baseline copies for read-only origins.
|
||||||
for path in set(user_profile_candidates(name, namespace)):
|
for path in set(runtime_profile_candidates(name, namespace)):
|
||||||
try:
|
try:
|
||||||
os.remove(path)
|
os.remove(path)
|
||||||
removed = True
|
removed = True
|
||||||
@@ -415,7 +443,7 @@ def delete_profile_files(name: str, namespace: str | None = None) -> bool:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if not read_only:
|
if not read_only:
|
||||||
for path in set(default_profile_candidates(name, namespace)):
|
for path in set(baseline_profile_candidates(name, namespace)):
|
||||||
try:
|
try:
|
||||||
os.remove(path)
|
os.remove(path)
|
||||||
removed = True
|
removed = True
|
||||||
@@ -443,7 +471,7 @@ SETTINGS_KEYS = {
|
|||||||
|
|
||||||
def list_profiles(namespace: str | None = None) -> list[str]:
|
def list_profiles(namespace: str | None = None) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Enumerate all known profile names, syncing bundled defaults when missing locally.
|
Enumerate all known profile names, syncing bundled baselines when missing locally.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
namespace (str | None, optional): Namespace label scoped to the profile set.
|
namespace (str | None, optional): Namespace label scoped to the profile set.
|
||||||
@@ -459,16 +487,27 @@ def list_profiles(namespace: str | None = None) -> list[str]:
|
|||||||
return set()
|
return set()
|
||||||
return {os.path.splitext(f)[0] for f in os.listdir(directory) if f.endswith(".ini")}
|
return {os.path.splitext(f)[0] for f in os.listdir(directory) if f.endswith(".ini")}
|
||||||
|
|
||||||
settings_dirs = {default_profiles_dir(namespace), user_profiles_dir(namespace)}
|
settings_dirs = {baseline_profiles_dir(namespace), runtime_profiles_dir(namespace)}
|
||||||
if ns:
|
if ns:
|
||||||
settings_dirs.add(default_profiles_dir(None))
|
settings_dirs.add(baseline_profiles_dir(None))
|
||||||
settings_dirs.add(user_profiles_dir(None))
|
settings_dirs.add(runtime_profiles_dir(None))
|
||||||
|
|
||||||
|
for segment in ("baseline", "runtime"):
|
||||||
|
for legacy_dir in [
|
||||||
|
_profiles_dir(_PROFILE_SEGMENT_ALIASES[segment][1], item)
|
||||||
|
for item in _candidate_namespaces(namespace)
|
||||||
|
]:
|
||||||
|
settings_dirs.add(legacy_dir)
|
||||||
|
|
||||||
settings_names: set[str] = set()
|
settings_names: set[str] = set()
|
||||||
for directory in settings_dirs:
|
for directory in settings_dirs:
|
||||||
settings_names |= _collect_from(directory)
|
settings_names |= _collect_from(directory)
|
||||||
|
|
||||||
# Also consider read-only defaults from core module and beamline plugin repositories
|
for name in sorted(settings_names):
|
||||||
|
runtime_profile_candidates(name, namespace)
|
||||||
|
baseline_profile_candidates(name, namespace)
|
||||||
|
|
||||||
|
# Also consider read-only baselines from core module and beamline plugin repositories
|
||||||
read_only_sources: dict[str, tuple[str, str]] = {}
|
read_only_sources: dict[str, tuple[str, str]] = {}
|
||||||
sources: list[tuple[str, str | None]] = [
|
sources: list[tuple[str, str | None]] = [
|
||||||
("module", module_profiles_dir()),
|
("module", module_profiles_dir()),
|
||||||
@@ -484,17 +523,17 @@ def list_profiles(namespace: str | None = None) -> list[str]:
|
|||||||
read_only_sources.setdefault(name, (origin, os.path.join(directory, filename)))
|
read_only_sources.setdefault(name, (origin, os.path.join(directory, filename)))
|
||||||
|
|
||||||
for name, (_origin, src) in sorted(read_only_sources.items()):
|
for name, (_origin, src) in sorted(read_only_sources.items()):
|
||||||
# Ensure a copy in the namespace-specific settings default directory
|
# Ensure a copy in the namespace-specific settings baseline directory.
|
||||||
dst_default = default_profile_path(name, namespace)
|
dst_baseline = baseline_profile_path(name, namespace)
|
||||||
if not os.path.exists(dst_default):
|
if not os.path.exists(dst_baseline):
|
||||||
os.makedirs(os.path.dirname(dst_default), exist_ok=True)
|
os.makedirs(os.path.dirname(dst_baseline), exist_ok=True)
|
||||||
shutil.copyfile(src, dst_default)
|
shutil.copy2(src, dst_baseline)
|
||||||
# Ensure a user copy exists to allow edits in the writable settings area
|
# Ensure a runtime copy exists to allow edits in the writable settings area.
|
||||||
dst_user = user_profile_path(name, namespace)
|
dst_runtime = runtime_profile_path(name, namespace)
|
||||||
if not os.path.exists(dst_user):
|
if not os.path.exists(dst_runtime):
|
||||||
os.makedirs(os.path.dirname(dst_user), exist_ok=True)
|
os.makedirs(os.path.dirname(dst_runtime), exist_ok=True)
|
||||||
shutil.copyfile(src, dst_user)
|
shutil.copy2(src, dst_runtime)
|
||||||
s = open_user_settings(name, namespace)
|
s = open_runtime_settings(name, namespace)
|
||||||
if s.value(SETTINGS_KEYS["created_at"], "") == "":
|
if s.value(SETTINGS_KEYS["created_at"], "") == "":
|
||||||
s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
|
s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
|
||||||
|
|
||||||
@@ -504,32 +543,34 @@ def list_profiles(namespace: str | None = None) -> list[str]:
|
|||||||
return sorted(settings_names)
|
return sorted(settings_names)
|
||||||
|
|
||||||
|
|
||||||
def open_default_settings(name: str, namespace: str | None = None) -> QSettings:
|
def open_baseline_settings(name: str, namespace: str | None = None) -> QSettings:
|
||||||
"""
|
"""
|
||||||
Open (and create if necessary) the default profile settings file.
|
Open (and create if necessary) the baseline profile settings file.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): Profile name without extension.
|
name (str): Profile name without extension.
|
||||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
QSettings: Settings instance targeting the default profile file.
|
QSettings: Settings instance targeting the baseline profile file.
|
||||||
"""
|
"""
|
||||||
return QSettings(default_profile_path(name, namespace), QSettings.IniFormat)
|
baseline_profile_candidates(name, namespace)
|
||||||
|
return QSettings(baseline_profile_path(name, namespace), QSettings.IniFormat)
|
||||||
|
|
||||||
|
|
||||||
def open_user_settings(name: str, namespace: str | None = None) -> QSettings:
|
def open_runtime_settings(name: str, namespace: str | None = None) -> QSettings:
|
||||||
"""
|
"""
|
||||||
Open (and create if necessary) the user profile settings file.
|
Open (and create if necessary) the runtime profile settings file.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): Profile name without extension.
|
name (str): Profile name without extension.
|
||||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
QSettings: Settings instance targeting the user profile file.
|
QSettings: Settings instance targeting the runtime profile file.
|
||||||
"""
|
"""
|
||||||
return QSettings(user_profile_path(name, namespace), QSettings.IniFormat)
|
runtime_profile_candidates(name, namespace)
|
||||||
|
return QSettings(runtime_profile_path(name, namespace), QSettings.IniFormat)
|
||||||
|
|
||||||
|
|
||||||
def _app_settings() -> QSettings:
|
def _app_settings() -> QSettings:
|
||||||
@@ -759,26 +800,26 @@ def read_manifest(settings: QSettings) -> list[dict]:
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
def restore_user_from_default(name: str, namespace: str | None = None) -> None:
|
def restore_runtime_from_baseline(name: str, namespace: str | None = None) -> None:
|
||||||
"""
|
"""
|
||||||
Copy the default profile to the user profile, preserving quick-select flag.
|
Copy the baseline profile to the runtime profile, preserving quick-select flag.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name(str): Profile name without extension.
|
name(str): Profile name without extension.
|
||||||
namespace(str | None, optional): Namespace label. Defaults to ``None``.
|
namespace(str | None, optional): Namespace label. Defaults to ``None``.
|
||||||
"""
|
"""
|
||||||
src = None
|
src = None
|
||||||
for candidate in default_profile_candidates(name, namespace):
|
for candidate in baseline_profile_candidates(name, namespace):
|
||||||
if os.path.exists(candidate):
|
if os.path.exists(candidate):
|
||||||
src = candidate
|
src = candidate
|
||||||
break
|
break
|
||||||
if not src:
|
if not src:
|
||||||
return
|
return
|
||||||
dst = user_profile_path(name, namespace)
|
dst = runtime_profile_path(name, namespace)
|
||||||
preserve_quick_select = is_quick_select(name, namespace)
|
preserve_quick_select = is_quick_select(name, namespace)
|
||||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||||
shutil.copyfile(src, dst)
|
shutil.copyfile(src, dst)
|
||||||
s = open_user_settings(name, namespace)
|
s = open_runtime_settings(name, namespace)
|
||||||
if not s.value(SETTINGS_KEYS["created_at"], ""):
|
if not s.value(SETTINGS_KEYS["created_at"], ""):
|
||||||
s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
|
s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
|
||||||
if preserve_quick_select:
|
if preserve_quick_select:
|
||||||
@@ -796,9 +837,9 @@ def is_quick_select(name: str, namespace: str | None = None) -> bool:
|
|||||||
Returns:
|
Returns:
|
||||||
bool: True if quick-select is enabled for the profile.
|
bool: True if quick-select is enabled for the profile.
|
||||||
"""
|
"""
|
||||||
s = _existing_user_settings(name, namespace)
|
s = _existing_runtime_settings(name, namespace)
|
||||||
if s is None:
|
if s is None:
|
||||||
s = _existing_default_settings(name, namespace)
|
s = _existing_baseline_settings(name, namespace)
|
||||||
if s is None:
|
if s is None:
|
||||||
return False
|
return False
|
||||||
return s.value(SETTINGS_KEYS["is_quick_select"], False, type=bool)
|
return s.value(SETTINGS_KEYS["is_quick_select"], False, type=bool)
|
||||||
@@ -813,13 +854,13 @@ def set_quick_select(name: str, enabled: bool, namespace: str | None = None) ->
|
|||||||
enabled(bool): True to enable quick-select, False to disable.
|
enabled(bool): True to enable quick-select, False to disable.
|
||||||
namespace(str | None, optional): Namespace label. Defaults to ``None``.
|
namespace(str | None, optional): Namespace label. Defaults to ``None``.
|
||||||
"""
|
"""
|
||||||
s = open_user_settings(name, namespace)
|
s = open_runtime_settings(name, namespace)
|
||||||
s.setValue(SETTINGS_KEYS["is_quick_select"], bool(enabled))
|
s.setValue(SETTINGS_KEYS["is_quick_select"], bool(enabled))
|
||||||
|
|
||||||
|
|
||||||
def list_quick_profiles(namespace: str | None = None) -> list[str]:
|
def list_quick_profiles(namespace: str | None = None) -> list[str]:
|
||||||
"""
|
"""
|
||||||
List only profiles that have quick-select enabled (user wins over default).
|
List only profiles that have quick-select enabled (runtime wins over baseline).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
namespace(str | None, optional): Namespace label. Defaults to ``None``.
|
namespace(str | None, optional): Namespace label. Defaults to ``None``.
|
||||||
@@ -909,8 +950,8 @@ class ProfileInfo(BaseModel):
|
|||||||
is_quick_select: bool = False
|
is_quick_select: bool = False
|
||||||
widget_count: int = 0
|
widget_count: int = 0
|
||||||
size_kb: int = 0
|
size_kb: int = 0
|
||||||
user_path: str = ""
|
runtime_path: str = ""
|
||||||
default_path: str = ""
|
baseline_path: str = ""
|
||||||
origin: ProfileOrigin = "unknown"
|
origin: ProfileOrigin = "unknown"
|
||||||
is_read_only: bool = False
|
is_read_only: bool = False
|
||||||
|
|
||||||
@@ -924,19 +965,19 @@ def get_profile_info(name: str, namespace: str | None = None) -> ProfileInfo:
|
|||||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ProfileInfo: Structured profile metadata, preferring the user copy when present.
|
ProfileInfo: Structured profile metadata, preferring the runtime copy when present.
|
||||||
"""
|
"""
|
||||||
user_paths = user_profile_candidates(name, namespace)
|
runtime_paths = runtime_profile_candidates(name, namespace)
|
||||||
default_paths = default_profile_candidates(name, namespace)
|
baseline_paths = baseline_profile_candidates(name, namespace)
|
||||||
u_path = next((p for p in user_paths if os.path.exists(p)), user_paths[0])
|
r_path = next((p for p in runtime_paths if os.path.exists(p)), runtime_paths[0])
|
||||||
d_path = next((p for p in default_paths if os.path.exists(p)), default_paths[0])
|
b_path = next((p for p in baseline_paths if os.path.exists(p)), baseline_paths[0])
|
||||||
origin = profile_origin(name, namespace)
|
origin = profile_origin(name, namespace)
|
||||||
read_only = origin in {"module", "plugin"}
|
read_only = origin in {"module", "plugin"}
|
||||||
prefer_user = os.path.exists(u_path)
|
prefer_runtime = os.path.exists(r_path)
|
||||||
if prefer_user:
|
if prefer_runtime:
|
||||||
s = QSettings(u_path, QSettings.IniFormat)
|
s = QSettings(r_path, QSettings.IniFormat)
|
||||||
elif os.path.exists(d_path):
|
elif os.path.exists(b_path):
|
||||||
s = QSettings(d_path, QSettings.IniFormat)
|
s = QSettings(b_path, QSettings.IniFormat)
|
||||||
else:
|
else:
|
||||||
s = None
|
s = None
|
||||||
if s is None:
|
if s is None:
|
||||||
@@ -957,14 +998,14 @@ def get_profile_info(name: str, namespace: str | None = None) -> ProfileInfo:
|
|||||||
is_quick_select=False,
|
is_quick_select=False,
|
||||||
widget_count=0,
|
widget_count=0,
|
||||||
size_kb=0,
|
size_kb=0,
|
||||||
user_path=u_path,
|
runtime_path=r_path,
|
||||||
default_path=d_path,
|
baseline_path=b_path,
|
||||||
origin=origin,
|
origin=origin,
|
||||||
is_read_only=read_only,
|
is_read_only=read_only,
|
||||||
)
|
)
|
||||||
|
|
||||||
created = s.value(SETTINGS_KEYS["created_at"], "", type=str) or now_iso_utc()
|
created = s.value(SETTINGS_KEYS["created_at"], "", type=str) or now_iso_utc()
|
||||||
src_path = u_path if prefer_user else d_path
|
src_path = r_path if prefer_runtime else b_path
|
||||||
modified = _file_modified_iso(src_path)
|
modified = _file_modified_iso(src_path)
|
||||||
count = _manifest_count(s)
|
count = _manifest_count(s)
|
||||||
try:
|
try:
|
||||||
@@ -990,8 +1031,8 @@ def get_profile_info(name: str, namespace: str | None = None) -> ProfileInfo:
|
|||||||
is_quick_select=is_quick_select(name, namespace),
|
is_quick_select=is_quick_select(name, namespace),
|
||||||
widget_count=count,
|
widget_count=count,
|
||||||
size_kb=size_kb,
|
size_kb=size_kb,
|
||||||
user_path=u_path,
|
runtime_path=r_path,
|
||||||
default_path=d_path,
|
baseline_path=b_path,
|
||||||
origin=origin,
|
origin=origin,
|
||||||
is_read_only=read_only,
|
is_read_only=read_only,
|
||||||
)
|
)
|
||||||
@@ -999,7 +1040,7 @@ def get_profile_info(name: str, namespace: str | None = None) -> ProfileInfo:
|
|||||||
|
|
||||||
def load_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None:
|
def load_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None:
|
||||||
"""
|
"""
|
||||||
Load the stored screenshot pixmap for a profile from settings (user preferred).
|
Load the stored screenshot pixmap for a profile from settings (runtime preferred).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): Profile name without extension.
|
name (str): Profile name without extension.
|
||||||
@@ -1008,17 +1049,17 @@ def load_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap
|
|||||||
Returns:
|
Returns:
|
||||||
QPixmap | None: Screenshot pixmap or ``None`` if unavailable.
|
QPixmap | None: Screenshot pixmap or ``None`` if unavailable.
|
||||||
"""
|
"""
|
||||||
s = _existing_user_settings(name, namespace)
|
s = _existing_runtime_settings(name, namespace)
|
||||||
if s is None:
|
if s is None:
|
||||||
s = _existing_default_settings(name, namespace)
|
s = _existing_baseline_settings(name, namespace)
|
||||||
if s is None:
|
if s is None:
|
||||||
return None
|
return None
|
||||||
return _load_screenshot_from_settings(s)
|
return _load_screenshot_from_settings(s)
|
||||||
|
|
||||||
|
|
||||||
def load_default_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None:
|
def load_baseline_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None:
|
||||||
"""
|
"""
|
||||||
Load the screenshot from the default profile copy, if available.
|
Load the screenshot from the baseline profile copy, if available.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): Profile name without extension.
|
name (str): Profile name without extension.
|
||||||
@@ -1027,15 +1068,15 @@ def load_default_profile_screenshot(name: str, namespace: str | None = None) ->
|
|||||||
Returns:
|
Returns:
|
||||||
QPixmap | None: Screenshot pixmap or ``None`` if unavailable.
|
QPixmap | None: Screenshot pixmap or ``None`` if unavailable.
|
||||||
"""
|
"""
|
||||||
s = _existing_default_settings(name, namespace)
|
s = _existing_baseline_settings(name, namespace)
|
||||||
if s is None:
|
if s is None:
|
||||||
return None
|
return None
|
||||||
return _load_screenshot_from_settings(s)
|
return _load_screenshot_from_settings(s)
|
||||||
|
|
||||||
|
|
||||||
def load_user_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None:
|
def load_runtime_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None:
|
||||||
"""
|
"""
|
||||||
Load the screenshot from the user profile copy, if available.
|
Load the screenshot from the runtime profile copy, if available.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): Profile name without extension.
|
name (str): Profile name without extension.
|
||||||
@@ -1044,7 +1085,7 @@ def load_user_profile_screenshot(name: str, namespace: str | None = None) -> QPi
|
|||||||
Returns:
|
Returns:
|
||||||
QPixmap | None: Screenshot pixmap or ``None`` if unavailable.
|
QPixmap | None: Screenshot pixmap or ``None`` if unavailable.
|
||||||
"""
|
"""
|
||||||
s = _existing_user_settings(name, namespace)
|
s = _existing_runtime_settings(name, namespace)
|
||||||
if s is None:
|
if s is None:
|
||||||
return None
|
return None
|
||||||
return _load_screenshot_from_settings(s)
|
return _load_screenshot_from_settings(s)
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ class SaveProfileDialog(QDialog):
|
|||||||
self,
|
self,
|
||||||
"Read-only profile",
|
"Read-only profile",
|
||||||
(
|
(
|
||||||
f"'{name}' is a default profile provided by {provider} and cannot be overwritten.\n"
|
f"'{name}' is a baseline profile provided by {provider} and cannot be overwritten.\n"
|
||||||
"Please choose a different name."
|
"Please choose a different name."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -179,7 +179,7 @@ class SaveProfileDialog(QDialog):
|
|||||||
"Overwrite profile",
|
"Overwrite profile",
|
||||||
(
|
(
|
||||||
f"A profile named '{name}' already exists.\n\n"
|
f"A profile named '{name}' already exists.\n\n"
|
||||||
"Overwriting will update both the saved profile and its restore default.\n"
|
"Overwriting will update both the runtime profile and its restore baseline.\n"
|
||||||
"Do you want to continue?"
|
"Do you want to continue?"
|
||||||
),
|
),
|
||||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
@@ -257,21 +257,24 @@ class PreviewPanel(QGroupBox):
|
|||||||
|
|
||||||
class RestoreProfileDialog(QDialog):
|
class RestoreProfileDialog(QDialog):
|
||||||
"""
|
"""
|
||||||
Confirmation dialog that previews the current profile screenshot against the default baseline.
|
Confirmation dialog that previews the current runtime screenshot against the baseline.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, parent: QWidget | None, current_pixmap: QPixmap | None, default_pixmap: QPixmap | None
|
self,
|
||||||
|
parent: QWidget | None,
|
||||||
|
current_pixmap: QPixmap | None,
|
||||||
|
baseline_pixmap: QPixmap | None,
|
||||||
):
|
):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Restore Profile to Default")
|
self.setWindowTitle("Restore Profile to Baseline")
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.resize(880, 480)
|
self.resize(880, 480)
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
info_label = QLabel(
|
info_label = QLabel(
|
||||||
"Restoring will discard your custom layout and replace it with the default profile."
|
"Restoring will discard your runtime layout and replace it with the baseline profile."
|
||||||
)
|
)
|
||||||
info_label.setWordWrap(True)
|
info_label.setWordWrap(True)
|
||||||
layout.addWidget(info_label)
|
layout.addWidget(info_label)
|
||||||
@@ -280,7 +283,7 @@ class RestoreProfileDialog(QDialog):
|
|||||||
layout.addLayout(preview_row)
|
layout.addLayout(preview_row)
|
||||||
|
|
||||||
current_preview = PreviewPanel("Current", current_pixmap, self)
|
current_preview = PreviewPanel("Current", current_pixmap, self)
|
||||||
default_preview = PreviewPanel("Default", default_pixmap, self)
|
baseline_preview = PreviewPanel("Baseline", baseline_pixmap, self)
|
||||||
|
|
||||||
# Equal expansion left/right
|
# Equal expansion left/right
|
||||||
preview_row.addWidget(current_preview, 1)
|
preview_row.addWidget(current_preview, 1)
|
||||||
@@ -292,7 +295,7 @@ class RestoreProfileDialog(QDialog):
|
|||||||
arrow_label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
|
arrow_label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
|
||||||
preview_row.addWidget(arrow_label)
|
preview_row.addWidget(arrow_label)
|
||||||
|
|
||||||
preview_row.addWidget(default_preview, 1)
|
preview_row.addWidget(baseline_preview, 1)
|
||||||
|
|
||||||
# Enforce equal stretch for both previews
|
# Enforce equal stretch for both previews
|
||||||
preview_row.setStretch(0, 1)
|
preview_row.setStretch(0, 1)
|
||||||
@@ -300,7 +303,7 @@ class RestoreProfileDialog(QDialog):
|
|||||||
preview_row.setStretch(2, 1)
|
preview_row.setStretch(2, 1)
|
||||||
|
|
||||||
warn_label = QLabel(
|
warn_label = QLabel(
|
||||||
"This action cannot be undone. Do you want to restore the default layout now?"
|
"This action cannot be undone. Do you want to restore the baseline layout now?"
|
||||||
)
|
)
|
||||||
warn_label.setWordWrap(True)
|
warn_label.setWordWrap(True)
|
||||||
layout.addWidget(warn_label)
|
layout.addWidget(warn_label)
|
||||||
@@ -324,7 +327,7 @@ class RestoreProfileDialog(QDialog):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def confirm(
|
def confirm(
|
||||||
parent: QWidget | None, current_pixmap: QPixmap | None, default_pixmap: QPixmap | None
|
parent: QWidget | None, current_pixmap: QPixmap | None, baseline_pixmap: QPixmap | None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
dialog = RestoreProfileDialog(parent, current_pixmap, default_pixmap)
|
dialog = RestoreProfileDialog(parent, current_pixmap, baseline_pixmap)
|
||||||
return dialog.exec() == QDialog.Accepted
|
return dialog.exec() == QDialog.Accepted
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class WorkSpaceManager(BECWidget, QWidget):
|
|||||||
HEADERS = ["Actions", "Profile", "Author"]
|
HEADERS = ["Actions", "Profile", "Author"]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, parent=None, target_widget=None, default_profile: str | None = None, **kwargs
|
self, parent=None, target_widget=None, active_profile: str | None = None, **kwargs
|
||||||
):
|
):
|
||||||
super().__init__(parent=parent, **kwargs)
|
super().__init__(parent=parent, **kwargs)
|
||||||
self.target_widget = target_widget
|
self.target_widget = target_widget
|
||||||
@@ -59,13 +59,13 @@ class WorkSpaceManager(BECWidget, QWidget):
|
|||||||
self._init_ui()
|
self._init_ui()
|
||||||
if self.target_widget is not None and hasattr(self.target_widget, "profile_changed"):
|
if self.target_widget is not None and hasattr(self.target_widget, "profile_changed"):
|
||||||
self.target_widget.profile_changed.connect(self.on_profile_changed)
|
self.target_widget.profile_changed.connect(self.on_profile_changed)
|
||||||
if default_profile is not None:
|
if active_profile is not None:
|
||||||
self._select_by_name(default_profile)
|
self._select_by_name(active_profile)
|
||||||
self._show_profile_details(default_profile)
|
self._show_profile_details(active_profile)
|
||||||
|
|
||||||
def _init_ui(self):
|
def _init_ui(self):
|
||||||
self.root_layout = QHBoxLayout(self)
|
self.root_layout = QHBoxLayout(self)
|
||||||
self.splitter = QSplitter(Qt.Horizontal, self)
|
self.splitter = QSplitter(Qt.Orientation.Horizontal, self)
|
||||||
self.root_layout.addWidget(self.splitter)
|
self.root_layout.addWidget(self.splitter)
|
||||||
|
|
||||||
# Init components
|
# Init components
|
||||||
@@ -89,7 +89,9 @@ class WorkSpaceManager(BECWidget, QWidget):
|
|||||||
left_panel.setMinimumWidth(220)
|
left_panel.setMinimumWidth(220)
|
||||||
|
|
||||||
# Make the screenshot preview expand to fill remaining space
|
# Make the screenshot preview expand to fill remaining space
|
||||||
self.screenshot_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
self.screenshot_label.setSizePolicy(
|
||||||
|
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
|
||||||
|
)
|
||||||
|
|
||||||
self.right_box = QGroupBox("Profile Screenshot Preview", self)
|
self.right_box = QGroupBox("Profile Screenshot Preview", self)
|
||||||
right_col = QVBoxLayout(self.right_box)
|
right_col = QVBoxLayout(self.right_box)
|
||||||
@@ -250,8 +252,8 @@ class WorkSpaceManager(BECWidget, QWidget):
|
|||||||
("Quick select", "Yes" if info.is_quick_select else "No"),
|
("Quick select", "Yes" if info.is_quick_select else "No"),
|
||||||
("Widgets", str(info.widget_count)),
|
("Widgets", str(info.widget_count)),
|
||||||
("Size (KB)", str(info.size_kb)),
|
("Size (KB)", str(info.size_kb)),
|
||||||
("User path", info.user_path or ""),
|
("Runtime path", info.runtime_path or ""),
|
||||||
("Default path", info.default_path or ""),
|
("Baseline path", info.baseline_path or ""),
|
||||||
]
|
]
|
||||||
for k, v in entries:
|
for k, v in entries:
|
||||||
self.profile_details_tree.addTopLevelItem(QTreeWidgetItem([k, v]))
|
self.profile_details_tree.addTopLevelItem(QTreeWidgetItem([k, v]))
|
||||||
|
|||||||
@@ -24,19 +24,9 @@ class ProfileComboBox(QComboBox):
|
|||||||
def set_quick_profile_provider(self, provider: Callable[[], list[str]]) -> None:
|
def set_quick_profile_provider(self, provider: Callable[[], list[str]]) -> None:
|
||||||
self._quick_provider = provider
|
self._quick_provider = provider
|
||||||
|
|
||||||
def refresh_profiles(
|
def _refresh_profiles(
|
||||||
self, active_profile: str | None = None, show_empty_profile: bool = False
|
self, current_text: str, active_profile: str | None = None, show_empty_profile: bool = False
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
|
||||||
Refresh the profile list and ensure the active profile is visible.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
active_profile(str | None): The currently active profile name.
|
|
||||||
show_empty_profile(bool): If True, show an explicit empty unsaved workspace entry.
|
|
||||||
"""
|
|
||||||
|
|
||||||
current_text = active_profile or self.currentText()
|
|
||||||
self.blockSignals(True)
|
|
||||||
self.clear()
|
self.clear()
|
||||||
|
|
||||||
quick_profiles = self._quick_provider()
|
quick_profiles = self._quick_provider()
|
||||||
@@ -103,7 +93,6 @@ class ProfileComboBox(QComboBox):
|
|||||||
if index >= 0:
|
if index >= 0:
|
||||||
self.setCurrentIndex(index)
|
self.setCurrentIndex(index)
|
||||||
|
|
||||||
self.blockSignals(False)
|
|
||||||
if active_profile and self.currentText() != active_profile:
|
if active_profile and self.currentText() != active_profile:
|
||||||
idx = self.findText(active_profile)
|
idx = self.findText(active_profile)
|
||||||
if idx >= 0:
|
if idx >= 0:
|
||||||
@@ -115,6 +104,24 @@ class ProfileComboBox(QComboBox):
|
|||||||
else:
|
else:
|
||||||
self.setToolTip("")
|
self.setToolTip("")
|
||||||
|
|
||||||
|
def refresh_profiles(
|
||||||
|
self, active_profile: str | None = None, show_empty_profile: bool = False
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Refresh the profile list and ensure the active profile is visible.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
active_profile(str | None): The currently active profile name.
|
||||||
|
show_empty_profile(bool): If True, show an explicit empty unsaved workspace entry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
current_text = active_profile or self.currentText()
|
||||||
|
was_blocked = self.blockSignals(True)
|
||||||
|
try:
|
||||||
|
self._refresh_profiles(current_text, active_profile, show_empty_profile)
|
||||||
|
finally:
|
||||||
|
self.blockSignals(was_blocked)
|
||||||
|
|
||||||
|
|
||||||
def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -> ToolbarBundle:
|
def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -> ToolbarBundle:
|
||||||
"""
|
"""
|
||||||
@@ -122,6 +129,7 @@ def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
components (ToolbarComponents): The components to be added to the bundle.
|
components (ToolbarComponents): The components to be added to the bundle.
|
||||||
|
enable_tools(bool): If True, show the workspace management tools; otherwise, only show the profile combo.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ToolbarBundle: The workspace toolbar bundle.
|
ToolbarBundle: The workspace toolbar bundle.
|
||||||
@@ -143,15 +151,15 @@ def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -
|
|||||||
components.get_action("save_workspace").action.setVisible(enable_tools)
|
components.get_action("save_workspace").action.setVisible(enable_tools)
|
||||||
|
|
||||||
components.add_safe(
|
components.add_safe(
|
||||||
"reset_default_workspace",
|
"reset_baseline_workspace",
|
||||||
MaterialIconAction(
|
MaterialIconAction(
|
||||||
icon_name="undo",
|
icon_name="undo",
|
||||||
tooltip="Refresh Current Workspace",
|
tooltip="Restore Baseline Profile",
|
||||||
checkable=False,
|
checkable=False,
|
||||||
parent=components.toolbar,
|
parent=components.toolbar,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
components.get_action("reset_default_workspace").action.setVisible(enable_tools)
|
components.get_action("reset_baseline_workspace").action.setVisible(enable_tools)
|
||||||
|
|
||||||
components.add_safe(
|
components.add_safe(
|
||||||
"manage_workspaces",
|
"manage_workspaces",
|
||||||
@@ -164,7 +172,7 @@ def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -
|
|||||||
bundle = ToolbarBundle("workspace", components)
|
bundle = ToolbarBundle("workspace", components)
|
||||||
bundle.add_action("workspace_combo")
|
bundle.add_action("workspace_combo")
|
||||||
bundle.add_action("save_workspace")
|
bundle.add_action("save_workspace")
|
||||||
bundle.add_action("reset_default_workspace")
|
bundle.add_action("reset_baseline_workspace")
|
||||||
bundle.add_action("manage_workspaces")
|
bundle.add_action("manage_workspaces")
|
||||||
return bundle
|
return bundle
|
||||||
|
|
||||||
@@ -194,9 +202,9 @@ class WorkspaceConnection(BundleConnection):
|
|||||||
self.target_widget.load_profile
|
self.target_widget.load_profile
|
||||||
)
|
)
|
||||||
|
|
||||||
reset_action = self.components.get_action("reset_default_workspace").action
|
reset_action = self.components.get_action("reset_baseline_workspace").action
|
||||||
if reset_action.isVisible():
|
if reset_action.isVisible():
|
||||||
reset_action.triggered.connect(self._reset_workspace_to_default)
|
reset_action.triggered.connect(self._reset_workspace_to_baseline)
|
||||||
|
|
||||||
manage_action = self.components.get_action("manage_workspaces").action
|
manage_action = self.components.get_action("manage_workspaces").action
|
||||||
if manage_action.isVisible():
|
if manage_action.isVisible():
|
||||||
@@ -213,9 +221,9 @@ class WorkspaceConnection(BundleConnection):
|
|||||||
self.target_widget.load_profile
|
self.target_widget.load_profile
|
||||||
)
|
)
|
||||||
|
|
||||||
reset_action = self.components.get_action("reset_default_workspace").action
|
reset_action = self.components.get_action("reset_baseline_workspace").action
|
||||||
if reset_action.isVisible():
|
if reset_action.isVisible():
|
||||||
reset_action.triggered.disconnect(self._reset_workspace_to_default)
|
reset_action.triggered.disconnect(self._reset_workspace_to_baseline)
|
||||||
|
|
||||||
manage_action = self.components.get_action("manage_workspaces").action
|
manage_action = self.components.get_action("manage_workspaces").action
|
||||||
if manage_action.isVisible():
|
if manage_action.isVisible():
|
||||||
@@ -223,8 +231,8 @@ class WorkspaceConnection(BundleConnection):
|
|||||||
self._connected = False
|
self._connected = False
|
||||||
|
|
||||||
@SafeSlot()
|
@SafeSlot()
|
||||||
def _reset_workspace_to_default(self):
|
def _reset_workspace_to_baseline(self):
|
||||||
"""
|
"""
|
||||||
Refreshes the current workspace.
|
Refreshes the current workspace.
|
||||||
"""
|
"""
|
||||||
self.target_widget.restore_user_profile_from_default()
|
self.target_widget.restore_baseline_profile(show_dialog=True)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from qtpy.QtWidgets import (
|
|||||||
)
|
)
|
||||||
from typeguard import typechecked
|
from typeguard import typechecked
|
||||||
|
|
||||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
from bec_widgets.utils.rpc_widget_handler import widget_handler
|
||||||
|
|
||||||
|
|
||||||
class LayoutManagerWidget(QWidget):
|
class LayoutManagerWidget(QWidget):
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ from qtpy.QtCore import QObject, QTimer
|
|||||||
from qtpy.QtWidgets import QApplication, QFrame, QMainWindow, QScrollArea, QWidget
|
from qtpy.QtWidgets import QApplication, QFrame, QMainWindow, QScrollArea, QWidget
|
||||||
|
|
||||||
from bec_widgets import SafeProperty, SafeSlot
|
from bec_widgets import SafeProperty, SafeSlot
|
||||||
from bec_widgets.utils import BECConnector
|
from bec_widgets.utils.bec_connector import BECConnector
|
||||||
from bec_widgets.utils.colors import apply_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
from bec_widgets.utils.widget_io import WidgetIO
|
from bec_widgets.utils.widget_io import WidgetIO
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import webbrowser
|
|||||||
class BECWebLinksMixin:
|
class BECWebLinksMixin:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def open_bec_docs():
|
def open_bec_docs():
|
||||||
webbrowser.open("https://beamline-experiment-control.readthedocs.io/en/latest/")
|
webbrowser.open("https://bec-project.github.io/bec_docs/")
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def open_bec_widgets_docs():
|
|
||||||
webbrowser.open("https://bec.readthedocs.io/projects/bec-widgets/en/latest/")
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def open_bec_bug_report():
|
def open_bec_bug_report():
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ from qtpy.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
import bec_widgets
|
import bec_widgets
|
||||||
from bec_widgets.utils import UILoader
|
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
from bec_widgets.utils.colors import apply_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
from bec_widgets.utils.error_popups import SafeSlot
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
|
from bec_widgets.utils.ui_loader import UILoader
|
||||||
from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget
|
from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget
|
||||||
from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_banner import (
|
from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_banner import (
|
||||||
BECNotificationBroker,
|
BECNotificationBroker,
|
||||||
@@ -355,17 +355,13 @@ class BECMainWindow(BECWidget, QMainWindow):
|
|||||||
|
|
||||||
bec_docs = QAction("BEC Docs", self)
|
bec_docs = QAction("BEC Docs", self)
|
||||||
bec_docs.setIcon(help_icon)
|
bec_docs.setIcon(help_icon)
|
||||||
widgets_docs = QAction("BEC Widgets Docs", self)
|
|
||||||
widgets_docs.setIcon(help_icon)
|
|
||||||
bug_report = QAction("Bug Report", self)
|
bug_report = QAction("Bug Report", self)
|
||||||
bug_report.setIcon(bug_icon)
|
bug_report.setIcon(bug_icon)
|
||||||
|
|
||||||
bec_docs.triggered.connect(BECWebLinksMixin.open_bec_docs)
|
bec_docs.triggered.connect(BECWebLinksMixin.open_bec_docs)
|
||||||
widgets_docs.triggered.connect(BECWebLinksMixin.open_bec_widgets_docs)
|
|
||||||
bug_report.triggered.connect(BECWebLinksMixin.open_bec_bug_report)
|
bug_report.triggered.connect(BECWebLinksMixin.open_bec_bug_report)
|
||||||
|
|
||||||
help_menu.addAction(bec_docs)
|
help_menu.addAction(bec_docs)
|
||||||
help_menu.addAction(widgets_docs)
|
|
||||||
help_menu.addAction(bug_report)
|
help_menu.addAction(bug_report)
|
||||||
|
|
||||||
################################################################################
|
################################################################################
|
||||||
|
|||||||
+1
-1
@@ -11,9 +11,9 @@ from qtpy.QtCore import Qt, Signal
|
|||||||
from qtpy.QtGui import QDoubleValidator
|
from qtpy.QtGui import QDoubleValidator
|
||||||
from qtpy.QtWidgets import QDoubleSpinBox
|
from qtpy.QtWidgets import QDoubleSpinBox
|
||||||
|
|
||||||
from bec_widgets.utils import UILoader
|
|
||||||
from bec_widgets.utils.colors import apply_theme, get_accent_colors
|
from bec_widgets.utils.colors import apply_theme, get_accent_colors
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
|
from bec_widgets.utils.ui_loader import UILoader
|
||||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base import (
|
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base import (
|
||||||
DeviceUpdateUIComponents,
|
DeviceUpdateUIComponents,
|
||||||
PositionerBoxBase,
|
PositionerBoxBase,
|
||||||
|
|||||||
+1
-1
@@ -12,9 +12,9 @@ from qtpy.QtCore import Signal
|
|||||||
from qtpy.QtGui import QDoubleValidator
|
from qtpy.QtGui import QDoubleValidator
|
||||||
from qtpy.QtWidgets import QDoubleSpinBox
|
from qtpy.QtWidgets import QDoubleSpinBox
|
||||||
|
|
||||||
from bec_widgets.utils import UILoader
|
|
||||||
from bec_widgets.utils.colors import apply_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
|
from bec_widgets.utils.ui_loader import UILoader
|
||||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base import (
|
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base import (
|
||||||
DeviceUpdateUIComponents,
|
DeviceUpdateUIComponents,
|
||||||
PositionerBoxBase,
|
PositionerBoxBase,
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ from bec_widgets.utils.bec_widget import BECWidget
|
|||||||
from bec_widgets.widgets.control.device_control.position_indicator.position_indicator import (
|
from bec_widgets.widgets.control.device_control.position_indicator.position_indicator import (
|
||||||
PositionIndicator,
|
PositionIndicator,
|
||||||
)
|
)
|
||||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
|
||||||
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
|
BECDeviceFilter,
|
||||||
DeviceLineEdit,
|
DeviceComboBox,
|
||||||
)
|
)
|
||||||
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||||
|
|
||||||
@@ -257,10 +257,10 @@ class PositionerBoxBase(BECWidget, QWidget):
|
|||||||
self._dialog = QDialog(self)
|
self._dialog = QDialog(self)
|
||||||
self._dialog.setWindowTitle("Positioner Selection")
|
self._dialog.setWindowTitle("Positioner Selection")
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
line_edit = DeviceLineEdit(
|
line_edit = DeviceComboBox(
|
||||||
self, client=self.client, device_filter=[BECDeviceFilter.POSITIONER]
|
self, client=self.client, device_filter=[BECDeviceFilter.POSITIONER]
|
||||||
)
|
)
|
||||||
line_edit.textChanged.connect(set_positioner)
|
line_edit.currentTextChanged.connect(set_positioner)
|
||||||
layout.addWidget(line_edit)
|
layout.addWidget(line_edit)
|
||||||
close_button = QPushButton("Close")
|
close_button = QPushButton("Close")
|
||||||
close_button.clicked.connect(self._dialog.accept)
|
close_button.clicked.connect(self._dialog.accept)
|
||||||
|
|||||||
@@ -1,458 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import enum
|
|
||||||
|
|
||||||
from bec_lib.device import ComputedSignal, Device, Positioner, ReadoutPriority
|
|
||||||
from bec_lib.device import Signal as BECSignal
|
|
||||||
from bec_lib.logger import bec_logger
|
|
||||||
from pydantic import field_validator
|
|
||||||
|
|
||||||
from bec_widgets.utils import ConnectionConfig
|
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
|
||||||
from bec_widgets.utils.filter_io import FilterIO
|
|
||||||
from bec_widgets.utils.widget_io import WidgetIO
|
|
||||||
|
|
||||||
logger = bec_logger.logger
|
|
||||||
|
|
||||||
|
|
||||||
class BECDeviceFilter(enum.Enum):
|
|
||||||
"""Filter for the device classes."""
|
|
||||||
|
|
||||||
DEVICE = "Device"
|
|
||||||
POSITIONER = "Positioner"
|
|
||||||
SIGNAL = "Signal"
|
|
||||||
COMPUTED_SIGNAL = "ComputedSignal"
|
|
||||||
|
|
||||||
|
|
||||||
class DeviceInputConfig(ConnectionConfig):
|
|
||||||
device_filter: list[str] = []
|
|
||||||
readout_filter: list[str] = []
|
|
||||||
devices: list[str] = []
|
|
||||||
default: str | None = None
|
|
||||||
arg_name: str | None = None
|
|
||||||
apply_filter: bool = True
|
|
||||||
signal_class_filter: list[str] = []
|
|
||||||
|
|
||||||
@field_validator("device_filter")
|
|
||||||
@classmethod
|
|
||||||
def check_device_filter(cls, v, values):
|
|
||||||
valid_device_filters = [entry.value for entry in BECDeviceFilter]
|
|
||||||
for filt in v:
|
|
||||||
if filt not in valid_device_filters:
|
|
||||||
raise ValueError(
|
|
||||||
f"Device filter {filt} is not a valid device filter {valid_device_filters}."
|
|
||||||
)
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("readout_filter")
|
|
||||||
@classmethod
|
|
||||||
def check_readout_filter(cls, v, values):
|
|
||||||
valid_device_filters = [entry.value for entry in ReadoutPriority]
|
|
||||||
for filt in v:
|
|
||||||
if filt not in valid_device_filters:
|
|
||||||
raise ValueError(
|
|
||||||
f"Device filter {filt} is not a valid device filter {valid_device_filters}."
|
|
||||||
)
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class DeviceInputBase(BECWidget):
|
|
||||||
"""
|
|
||||||
Mixin base class for device input widgets.
|
|
||||||
It allows to filter devices from BEC based on
|
|
||||||
device class and readout priority.
|
|
||||||
"""
|
|
||||||
|
|
||||||
_device_handler = {
|
|
||||||
BECDeviceFilter.DEVICE: Device,
|
|
||||||
BECDeviceFilter.POSITIONER: Positioner,
|
|
||||||
BECDeviceFilter.SIGNAL: BECSignal,
|
|
||||||
BECDeviceFilter.COMPUTED_SIGNAL: ComputedSignal,
|
|
||||||
}
|
|
||||||
|
|
||||||
_filter_handler = {
|
|
||||||
BECDeviceFilter.DEVICE: "filter_to_device",
|
|
||||||
BECDeviceFilter.POSITIONER: "filter_to_positioner",
|
|
||||||
BECDeviceFilter.SIGNAL: "filter_to_signal",
|
|
||||||
BECDeviceFilter.COMPUTED_SIGNAL: "filter_to_computed_signal",
|
|
||||||
ReadoutPriority.MONITORED: "readout_monitored",
|
|
||||||
ReadoutPriority.BASELINE: "readout_baseline",
|
|
||||||
ReadoutPriority.ASYNC: "readout_async",
|
|
||||||
ReadoutPriority.CONTINUOUS: "readout_continuous",
|
|
||||||
ReadoutPriority.ON_REQUEST: "readout_on_request",
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, parent=None, client=None, config=None, gui_id: str | None = None, **kwargs):
|
|
||||||
|
|
||||||
if config is None:
|
|
||||||
config = DeviceInputConfig(widget_class=self.__class__.__name__)
|
|
||||||
else:
|
|
||||||
if isinstance(config, dict):
|
|
||||||
config = DeviceInputConfig(**config)
|
|
||||||
self.config = config
|
|
||||||
super().__init__(
|
|
||||||
parent=parent, client=client, config=config, gui_id=gui_id, theme_update=True, **kwargs
|
|
||||||
)
|
|
||||||
self.get_bec_shortcuts()
|
|
||||||
self._device_filter = []
|
|
||||||
self._readout_filter = []
|
|
||||||
self._devices = []
|
|
||||||
|
|
||||||
### QtSlots ###
|
|
||||||
|
|
||||||
@SafeSlot(str)
|
|
||||||
def set_device(self, device: str):
|
|
||||||
"""
|
|
||||||
Set the device.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
device (str): Default name.
|
|
||||||
"""
|
|
||||||
if self.validate_device(device) is True:
|
|
||||||
WidgetIO.set_value(widget=self, value=device)
|
|
||||||
self.config.default = device
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
f"Device {device} is not in the filtered selection of {self}: {self.devices}."
|
|
||||||
)
|
|
||||||
|
|
||||||
@SafeSlot()
|
|
||||||
def update_devices_from_filters(self):
|
|
||||||
"""Update the devices based on the current filter selection
|
|
||||||
in self.device_filter and self.readout_filter. If apply_filter is False,
|
|
||||||
it will not apply the filters, store the filter settings and return.
|
|
||||||
"""
|
|
||||||
current_device = WidgetIO.get_value(widget=self, as_string=True)
|
|
||||||
self.config.device_filter = self.device_filter
|
|
||||||
self.config.readout_filter = self.readout_filter
|
|
||||||
self.config.signal_class_filter = self.signal_class_filter
|
|
||||||
if self.apply_filter is False:
|
|
||||||
return
|
|
||||||
all_dev = self.dev.enabled_devices
|
|
||||||
devs = self._filter_devices_by_signal_class(all_dev)
|
|
||||||
# Filter based on device class
|
|
||||||
devs = [dev for dev in devs if self._check_device_filter(dev)]
|
|
||||||
# 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]
|
|
||||||
if current_device != "":
|
|
||||||
self.set_device(current_device)
|
|
||||||
|
|
||||||
@SafeSlot(list)
|
|
||||||
def set_available_devices(self, devices: list[str]):
|
|
||||||
"""
|
|
||||||
Set the devices. If a device in the list is not valid, it will not be considered.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
devices (list[str]): List of devices.
|
|
||||||
"""
|
|
||||||
self.apply_filter = False
|
|
||||||
self.devices = devices
|
|
||||||
|
|
||||||
### QtProperties ###
|
|
||||||
|
|
||||||
@SafeProperty(
|
|
||||||
"QStringList",
|
|
||||||
doc="List of devices. If updated, it will disable the apply filters property.",
|
|
||||||
)
|
|
||||||
def devices(self) -> list[str]:
|
|
||||||
"""
|
|
||||||
Get the list of devices for the applied filters.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[str]: List of devices.
|
|
||||||
"""
|
|
||||||
return self._devices
|
|
||||||
|
|
||||||
@devices.setter
|
|
||||||
def devices(self, value: list):
|
|
||||||
self._devices = value
|
|
||||||
self.config.devices = value
|
|
||||||
FilterIO.set_selection(widget=self, selection=value)
|
|
||||||
|
|
||||||
@SafeProperty(str)
|
|
||||||
def default(self):
|
|
||||||
"""Get the default device name. If set through this property, it will update only if the device is within the filtered selection."""
|
|
||||||
return self.config.default
|
|
||||||
|
|
||||||
@default.setter
|
|
||||||
def default(self, value: str):
|
|
||||||
if self.validate_device(value) is False:
|
|
||||||
return
|
|
||||||
self.config.default = value
|
|
||||||
WidgetIO.set_value(widget=self, value=value)
|
|
||||||
|
|
||||||
@SafeProperty(bool)
|
|
||||||
def apply_filter(self):
|
|
||||||
"""Apply the filters on the devices."""
|
|
||||||
return self.config.apply_filter
|
|
||||||
|
|
||||||
@apply_filter.setter
|
|
||||||
def apply_filter(self, value: bool):
|
|
||||||
self.config.apply_filter = value
|
|
||||||
self.update_devices_from_filters()
|
|
||||||
|
|
||||||
@SafeProperty("QStringList")
|
|
||||||
def signal_class_filter(self) -> list[str]:
|
|
||||||
"""
|
|
||||||
Get the signal class filter for devices.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[str]: List of signal class names used for filtering devices.
|
|
||||||
"""
|
|
||||||
return self.config.signal_class_filter
|
|
||||||
|
|
||||||
@signal_class_filter.setter
|
|
||||||
def signal_class_filter(self, value: list[str] | None):
|
|
||||||
"""
|
|
||||||
Set the signal class filter and update the device list.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
value (list[str] | None): List of signal class names to filter by.
|
|
||||||
"""
|
|
||||||
self.config.signal_class_filter = value or []
|
|
||||||
self.update_devices_from_filters()
|
|
||||||
|
|
||||||
@SafeProperty(bool)
|
|
||||||
def filter_to_device(self):
|
|
||||||
"""Include devices in filters."""
|
|
||||||
return BECDeviceFilter.DEVICE in self.device_filter
|
|
||||||
|
|
||||||
@filter_to_device.setter
|
|
||||||
def filter_to_device(self, value: bool):
|
|
||||||
if value is True and BECDeviceFilter.DEVICE not in self.device_filter:
|
|
||||||
self._device_filter.append(BECDeviceFilter.DEVICE)
|
|
||||||
if value is False and BECDeviceFilter.DEVICE in self.device_filter:
|
|
||||||
self._device_filter.remove(BECDeviceFilter.DEVICE)
|
|
||||||
self.update_devices_from_filters()
|
|
||||||
|
|
||||||
@SafeProperty(bool)
|
|
||||||
def filter_to_positioner(self):
|
|
||||||
"""Include devices of type Positioner in filters."""
|
|
||||||
return BECDeviceFilter.POSITIONER in self.device_filter
|
|
||||||
|
|
||||||
@filter_to_positioner.setter
|
|
||||||
def filter_to_positioner(self, value: bool):
|
|
||||||
if value is True and BECDeviceFilter.POSITIONER not in self.device_filter:
|
|
||||||
self._device_filter.append(BECDeviceFilter.POSITIONER)
|
|
||||||
if value is False and BECDeviceFilter.POSITIONER in self.device_filter:
|
|
||||||
self._device_filter.remove(BECDeviceFilter.POSITIONER)
|
|
||||||
self.update_devices_from_filters()
|
|
||||||
|
|
||||||
@SafeProperty(bool)
|
|
||||||
def filter_to_signal(self):
|
|
||||||
"""Include devices of type Signal in filters."""
|
|
||||||
return BECDeviceFilter.SIGNAL in self.device_filter
|
|
||||||
|
|
||||||
@filter_to_signal.setter
|
|
||||||
def filter_to_signal(self, value: bool):
|
|
||||||
if value is True and BECDeviceFilter.SIGNAL not in self.device_filter:
|
|
||||||
self._device_filter.append(BECDeviceFilter.SIGNAL)
|
|
||||||
if value is False and BECDeviceFilter.SIGNAL in self.device_filter:
|
|
||||||
self._device_filter.remove(BECDeviceFilter.SIGNAL)
|
|
||||||
self.update_devices_from_filters()
|
|
||||||
|
|
||||||
@SafeProperty(bool)
|
|
||||||
def filter_to_computed_signal(self):
|
|
||||||
"""Include devices of type ComputedSignal in filters."""
|
|
||||||
return BECDeviceFilter.COMPUTED_SIGNAL in self.device_filter
|
|
||||||
|
|
||||||
@filter_to_computed_signal.setter
|
|
||||||
def filter_to_computed_signal(self, value: bool):
|
|
||||||
if value is True and BECDeviceFilter.COMPUTED_SIGNAL not in self.device_filter:
|
|
||||||
self._device_filter.append(BECDeviceFilter.COMPUTED_SIGNAL)
|
|
||||||
if value is False and BECDeviceFilter.COMPUTED_SIGNAL in self.device_filter:
|
|
||||||
self._device_filter.remove(BECDeviceFilter.COMPUTED_SIGNAL)
|
|
||||||
self.update_devices_from_filters()
|
|
||||||
|
|
||||||
@SafeProperty(bool)
|
|
||||||
def readout_monitored(self):
|
|
||||||
"""Include devices with readout priority Monitored in filters."""
|
|
||||||
return ReadoutPriority.MONITORED in self.readout_filter
|
|
||||||
|
|
||||||
@readout_monitored.setter
|
|
||||||
def readout_monitored(self, value: bool):
|
|
||||||
if value is True and ReadoutPriority.MONITORED not in self.readout_filter:
|
|
||||||
self._readout_filter.append(ReadoutPriority.MONITORED)
|
|
||||||
if value is False and ReadoutPriority.MONITORED in self.readout_filter:
|
|
||||||
self._readout_filter.remove(ReadoutPriority.MONITORED)
|
|
||||||
self.update_devices_from_filters()
|
|
||||||
|
|
||||||
@SafeProperty(bool)
|
|
||||||
def readout_baseline(self):
|
|
||||||
"""Include devices with readout priority Baseline in filters."""
|
|
||||||
return ReadoutPriority.BASELINE in self.readout_filter
|
|
||||||
|
|
||||||
@readout_baseline.setter
|
|
||||||
def readout_baseline(self, value: bool):
|
|
||||||
if value is True and ReadoutPriority.BASELINE not in self.readout_filter:
|
|
||||||
self._readout_filter.append(ReadoutPriority.BASELINE)
|
|
||||||
if value is False and ReadoutPriority.BASELINE in self.readout_filter:
|
|
||||||
self._readout_filter.remove(ReadoutPriority.BASELINE)
|
|
||||||
self.update_devices_from_filters()
|
|
||||||
|
|
||||||
@SafeProperty(bool)
|
|
||||||
def readout_async(self):
|
|
||||||
"""Include devices with readout priority Async in filters."""
|
|
||||||
return ReadoutPriority.ASYNC in self.readout_filter
|
|
||||||
|
|
||||||
@readout_async.setter
|
|
||||||
def readout_async(self, value: bool):
|
|
||||||
if value is True and ReadoutPriority.ASYNC not in self.readout_filter:
|
|
||||||
self._readout_filter.append(ReadoutPriority.ASYNC)
|
|
||||||
if value is False and ReadoutPriority.ASYNC in self.readout_filter:
|
|
||||||
self._readout_filter.remove(ReadoutPriority.ASYNC)
|
|
||||||
self.update_devices_from_filters()
|
|
||||||
|
|
||||||
@SafeProperty(bool)
|
|
||||||
def readout_continuous(self):
|
|
||||||
"""Include devices with readout priority continuous in filters."""
|
|
||||||
return ReadoutPriority.CONTINUOUS in self.readout_filter
|
|
||||||
|
|
||||||
@readout_continuous.setter
|
|
||||||
def readout_continuous(self, value: bool):
|
|
||||||
if value is True and ReadoutPriority.CONTINUOUS not in self.readout_filter:
|
|
||||||
self._readout_filter.append(ReadoutPriority.CONTINUOUS)
|
|
||||||
if value is False and ReadoutPriority.CONTINUOUS in self.readout_filter:
|
|
||||||
self._readout_filter.remove(ReadoutPriority.CONTINUOUS)
|
|
||||||
self.update_devices_from_filters()
|
|
||||||
|
|
||||||
@SafeProperty(bool)
|
|
||||||
def readout_on_request(self):
|
|
||||||
"""Include devices with readout priority OnRequest in filters."""
|
|
||||||
return ReadoutPriority.ON_REQUEST in self.readout_filter
|
|
||||||
|
|
||||||
@readout_on_request.setter
|
|
||||||
def readout_on_request(self, value: bool):
|
|
||||||
if value is True and ReadoutPriority.ON_REQUEST not in self.readout_filter:
|
|
||||||
self._readout_filter.append(ReadoutPriority.ON_REQUEST)
|
|
||||||
if value is False and ReadoutPriority.ON_REQUEST in self.readout_filter:
|
|
||||||
self._readout_filter.remove(ReadoutPriority.ON_REQUEST)
|
|
||||||
self.update_devices_from_filters()
|
|
||||||
|
|
||||||
### Python Methods and Properties ###
|
|
||||||
|
|
||||||
@property
|
|
||||||
def device_filter(self) -> list[object]:
|
|
||||||
"""Get the list of filters to apply on the devices."""
|
|
||||||
return self._device_filter
|
|
||||||
|
|
||||||
@property
|
|
||||||
def readout_filter(self) -> list[str]:
|
|
||||||
"""Get the list of filters to apply on the devices"""
|
|
||||||
return self._readout_filter
|
|
||||||
|
|
||||||
def get_available_filters(self) -> list:
|
|
||||||
"""Get the available filters."""
|
|
||||||
return [entry for entry in BECDeviceFilter]
|
|
||||||
|
|
||||||
def get_readout_priority_filters(self) -> list:
|
|
||||||
"""Get the available readout priority filters."""
|
|
||||||
return [entry for entry in ReadoutPriority]
|
|
||||||
|
|
||||||
def set_device_filter(
|
|
||||||
self, filter_selection: str | BECDeviceFilter | list[str] | list[BECDeviceFilter]
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Set the device filter. If None is passed, no filters are applied and all devices included.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filter_selection (str | list[str]): Device filters. It is recommended to make an enum for the filters.
|
|
||||||
"""
|
|
||||||
filters = None
|
|
||||||
if isinstance(filter_selection, list):
|
|
||||||
filters = [self._filter_handler.get(entry) for entry in filter_selection]
|
|
||||||
if isinstance(filter_selection, str) or isinstance(filter_selection, BECDeviceFilter):
|
|
||||||
filters = [self._filter_handler.get(filter_selection)]
|
|
||||||
if filters is None or any([entry is None for entry in filters]):
|
|
||||||
logger.warning(f"Device filter {filter_selection} is not in the device filter list.")
|
|
||||||
return
|
|
||||||
for entry in filters:
|
|
||||||
setattr(self, entry, True)
|
|
||||||
|
|
||||||
def set_readout_priority_filter(
|
|
||||||
self, filter_selection: str | ReadoutPriority | list[str] | list[ReadoutPriority]
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Set the readout priority filter. If None is passed, all filters are included.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filter_selection (str | list[str]): Readout priority filters.
|
|
||||||
"""
|
|
||||||
filters = None
|
|
||||||
if isinstance(filter_selection, list):
|
|
||||||
filters = [self._filter_handler.get(entry) for entry in filter_selection]
|
|
||||||
if isinstance(filter_selection, str) or isinstance(filter_selection, ReadoutPriority):
|
|
||||||
filters = [self._filter_handler.get(filter_selection)]
|
|
||||||
if filters is None or any([entry is None for entry in filters]):
|
|
||||||
logger.warning(
|
|
||||||
f"Readout priority filter {filter_selection} is not in the readout priority list."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
for entry in filters:
|
|
||||||
setattr(self, entry, True)
|
|
||||||
|
|
||||||
def _check_device_filter(
|
|
||||||
self, device: Device | BECSignal | ComputedSignal | Positioner
|
|
||||||
) -> bool:
|
|
||||||
"""Check if filter for device type is applied or not.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
device(Device | Signal | ComputedSignal | Positioner): Device object.
|
|
||||||
"""
|
|
||||||
return all(isinstance(device, self._device_handler[entry]) for entry in self.device_filter)
|
|
||||||
|
|
||||||
def _filter_devices_by_signal_class(
|
|
||||||
self, devices: list[Device | BECSignal | ComputedSignal | Positioner]
|
|
||||||
) -> list[Device | BECSignal | ComputedSignal | Positioner]:
|
|
||||||
"""Filter devices by signal class, if a signal class filter is set."""
|
|
||||||
if not self.config.signal_class_filter:
|
|
||||||
return devices
|
|
||||||
if not self.client or not hasattr(self.client, "device_manager"):
|
|
||||||
return []
|
|
||||||
signals = FilterIO.update_with_signal_class(
|
|
||||||
widget=self, signal_class_filter=self.config.signal_class_filter, client=self.client
|
|
||||||
)
|
|
||||||
allowed_devices = {device_name for device_name, _, _ in signals}
|
|
||||||
return [dev for dev in devices if dev.name in allowed_devices]
|
|
||||||
|
|
||||||
def _check_readout_filter(
|
|
||||||
self, device: Device | BECSignal | ComputedSignal | Positioner
|
|
||||||
) -> bool:
|
|
||||||
"""Check if filter for readout priority is applied or not.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
device(Device | Signal | ComputedSignal | Positioner): Device object.
|
|
||||||
"""
|
|
||||||
return device.readout_priority in self.readout_filter
|
|
||||||
|
|
||||||
def get_device_object(self, device: str) -> object:
|
|
||||||
"""
|
|
||||||
Get the device object based on the device name.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
device(str): Device name.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
|
|
||||||
"""
|
|
||||||
self.validate_device(device)
|
|
||||||
dev = getattr(self.dev, device, None)
|
|
||||||
if dev is None:
|
|
||||||
raise ValueError(
|
|
||||||
f"Device {device} is not found in the device manager {self.dev} as enabled device."
|
|
||||||
)
|
|
||||||
return dev
|
|
||||||
|
|
||||||
def validate_device(self, device: str) -> bool:
|
|
||||||
"""
|
|
||||||
Validate the device if it is present in the filtered device selection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
device(str): Device to validate.
|
|
||||||
"""
|
|
||||||
all_devs = [dev.name for dev in self.dev.enabled_devices]
|
|
||||||
if device in self.devices and device in all_devs:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
from bec_lib.callback_handler import EventType
|
|
||||||
from bec_lib.device import Signal
|
|
||||||
from bec_lib.logger import bec_logger
|
|
||||||
from qtpy.QtCore import Property
|
|
||||||
|
|
||||||
from bec_widgets.utils import ConnectionConfig
|
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
|
||||||
from bec_widgets.utils.error_popups import SafeSlot
|
|
||||||
from bec_widgets.utils.filter_io import FilterIO
|
|
||||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
|
||||||
from bec_widgets.utils.widget_io import WidgetIO
|
|
||||||
|
|
||||||
logger = bec_logger.logger
|
|
||||||
|
|
||||||
|
|
||||||
class DeviceSignalInputBaseConfig(ConnectionConfig):
|
|
||||||
"""Configuration class for DeviceSignalInputBase."""
|
|
||||||
|
|
||||||
signal_filter: str | list[str] | None = None
|
|
||||||
signal_class_filter: list[str] | None = None
|
|
||||||
ndim_filter: int | list[int] | None = None
|
|
||||||
default: str | None = None
|
|
||||||
arg_name: str | None = None
|
|
||||||
device: str | None = None
|
|
||||||
signals: list[str] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class DeviceSignalInputBase(BECWidget):
|
|
||||||
"""
|
|
||||||
Mixin base class for device signal input widgets.
|
|
||||||
Mixin class for device signal input widgets. This class provides methods to get the device signal list and device
|
|
||||||
signal object based on the current text of the widget.
|
|
||||||
"""
|
|
||||||
|
|
||||||
RPC = False
|
|
||||||
_filter_handler = {
|
|
||||||
Kind.hinted: "include_hinted_signals",
|
|
||||||
Kind.normal: "include_normal_signals",
|
|
||||||
Kind.config: "include_config_signals",
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
client=None,
|
|
||||||
config: DeviceSignalInputBaseConfig | dict | None = None,
|
|
||||||
gui_id: str = None,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
|
|
||||||
self.config = self._process_config_input(config)
|
|
||||||
super().__init__(client=client, config=self.config, gui_id=gui_id, **kwargs)
|
|
||||||
|
|
||||||
self._device = None
|
|
||||||
self.get_bec_shortcuts()
|
|
||||||
self._signal_filter = set()
|
|
||||||
self._signals = []
|
|
||||||
self._hinted_signals = []
|
|
||||||
self._normal_signals = []
|
|
||||||
self._config_signals = []
|
|
||||||
self._device_update_register = self.bec_dispatcher.client.callbacks.register(
|
|
||||||
EventType.DEVICE_UPDATE, self.update_signals_from_filters
|
|
||||||
)
|
|
||||||
|
|
||||||
### Qt Slots ###
|
|
||||||
|
|
||||||
@SafeSlot(str)
|
|
||||||
def set_signal(self, signal: str):
|
|
||||||
"""
|
|
||||||
Set the signal.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
signal (str): signal name.
|
|
||||||
"""
|
|
||||||
if self.validate_signal(signal):
|
|
||||||
WidgetIO.set_value(widget=self, value=signal)
|
|
||||||
self.config.default = signal
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
f"Signal {signal} not found for device {self.device} and filtered selection {self.signal_filter}."
|
|
||||||
)
|
|
||||||
|
|
||||||
@SafeSlot(str)
|
|
||||||
def set_device(self, device: str | None):
|
|
||||||
"""
|
|
||||||
Set the device. If device is not valid, device will be set to None which happens
|
|
||||||
|
|
||||||
Args:
|
|
||||||
device(str): device name.
|
|
||||||
"""
|
|
||||||
if self.validate_device(device) is False:
|
|
||||||
self._device = None
|
|
||||||
else:
|
|
||||||
self._device = device
|
|
||||||
self.update_signals_from_filters()
|
|
||||||
|
|
||||||
@SafeSlot(dict, dict)
|
|
||||||
@SafeSlot()
|
|
||||||
def update_signals_from_filters(
|
|
||||||
self, content: dict | None = None, metadata: dict | None = None
|
|
||||||
):
|
|
||||||
"""Update the filters for the device signals based on list in self.signal_filter.
|
|
||||||
In addition, store the hinted, normal and config signals in separate lists to allow
|
|
||||||
customisation within QLineEdit.
|
|
||||||
|
|
||||||
Note:
|
|
||||||
Signal and ComputedSignals have no signals. The naming convention follows the device name.
|
|
||||||
"""
|
|
||||||
self.config.signal_filter = self.signal_filter
|
|
||||||
# pylint: disable=protected-access
|
|
||||||
if not self.validate_device(self._device):
|
|
||||||
self._device = None
|
|
||||||
self.config.device = self._device
|
|
||||||
self._signals = []
|
|
||||||
self._hinted_signals = []
|
|
||||||
self._normal_signals = []
|
|
||||||
self._config_signals = []
|
|
||||||
FilterIO.set_selection(widget=self, selection=self._signals)
|
|
||||||
return
|
|
||||||
device = self.get_device_object(self._device)
|
|
||||||
device_info = device._info.get("signals", {})
|
|
||||||
|
|
||||||
# See above convention for Signals and ComputedSignals
|
|
||||||
if isinstance(device, Signal):
|
|
||||||
self._signals = [(self._device, {})]
|
|
||||||
self._hinted_signals = [(self._device, {})]
|
|
||||||
self._normal_signals = []
|
|
||||||
self._config_signals = []
|
|
||||||
FilterIO.set_selection(widget=self, selection=self._signals)
|
|
||||||
return
|
|
||||||
|
|
||||||
def _update(kind: Kind):
|
|
||||||
return FilterIO.update_with_kind(
|
|
||||||
widget=self,
|
|
||||||
kind=kind,
|
|
||||||
signal_filter=self.signal_filter,
|
|
||||||
device_info=device_info,
|
|
||||||
device_name=self._device,
|
|
||||||
)
|
|
||||||
|
|
||||||
self._hinted_signals = _update(Kind.hinted)
|
|
||||||
self._normal_signals = _update(Kind.normal)
|
|
||||||
self._config_signals = _update(Kind.config)
|
|
||||||
|
|
||||||
self._signals = self._hinted_signals + self._normal_signals + self._config_signals
|
|
||||||
FilterIO.set_selection(widget=self, selection=self.signals)
|
|
||||||
|
|
||||||
### Qt Properties ###
|
|
||||||
|
|
||||||
@Property(str)
|
|
||||||
def device(self) -> str:
|
|
||||||
"""Get the selected device."""
|
|
||||||
if self._device is None:
|
|
||||||
return ""
|
|
||||||
return self._device
|
|
||||||
|
|
||||||
@device.setter
|
|
||||||
def device(self, value: str):
|
|
||||||
"""Set the device and update the filters, only allow devices present in the devicemanager."""
|
|
||||||
self._device = value
|
|
||||||
self.config.device = value
|
|
||||||
self.update_signals_from_filters()
|
|
||||||
|
|
||||||
@Property(bool)
|
|
||||||
def include_hinted_signals(self):
|
|
||||||
"""Include hinted signals in filters."""
|
|
||||||
return Kind.hinted in self.signal_filter
|
|
||||||
|
|
||||||
@include_hinted_signals.setter
|
|
||||||
def include_hinted_signals(self, value: bool):
|
|
||||||
if value:
|
|
||||||
self._signal_filter.add(Kind.hinted)
|
|
||||||
else:
|
|
||||||
self._signal_filter.discard(Kind.hinted)
|
|
||||||
self.update_signals_from_filters()
|
|
||||||
|
|
||||||
@Property(bool)
|
|
||||||
def include_normal_signals(self):
|
|
||||||
"""Include normal signals in filters."""
|
|
||||||
return Kind.normal in self.signal_filter
|
|
||||||
|
|
||||||
@include_normal_signals.setter
|
|
||||||
def include_normal_signals(self, value: bool):
|
|
||||||
if value:
|
|
||||||
self._signal_filter.add(Kind.normal)
|
|
||||||
else:
|
|
||||||
self._signal_filter.discard(Kind.normal)
|
|
||||||
self.update_signals_from_filters()
|
|
||||||
|
|
||||||
@Property(bool)
|
|
||||||
def include_config_signals(self):
|
|
||||||
"""Include config signals in filters."""
|
|
||||||
return Kind.config in self.signal_filter
|
|
||||||
|
|
||||||
@include_config_signals.setter
|
|
||||||
def include_config_signals(self, value: bool):
|
|
||||||
if value:
|
|
||||||
self._signal_filter.add(Kind.config)
|
|
||||||
else:
|
|
||||||
self._signal_filter.discard(Kind.config)
|
|
||||||
self.update_signals_from_filters()
|
|
||||||
|
|
||||||
### Properties and Methods ###
|
|
||||||
|
|
||||||
@property
|
|
||||||
def signals(self) -> list[str]:
|
|
||||||
"""
|
|
||||||
Get the list of device signals for the applied filters.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[str]: List of device signals.
|
|
||||||
"""
|
|
||||||
return self._signals
|
|
||||||
|
|
||||||
@signals.setter
|
|
||||||
def signals(self, value: list[str]):
|
|
||||||
self._signals = value
|
|
||||||
self.config.signals = value
|
|
||||||
FilterIO.set_selection(widget=self, selection=value)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def signal_filter(self) -> list[str]:
|
|
||||||
"""Get the list of filters to apply on the device signals."""
|
|
||||||
return self._signal_filter
|
|
||||||
|
|
||||||
def get_available_filters(self) -> list[str]:
|
|
||||||
"""Get the available filters."""
|
|
||||||
return [entry for entry in self._filter_handler]
|
|
||||||
|
|
||||||
def set_filter(self, filter_selection: str | list[str]):
|
|
||||||
"""
|
|
||||||
Set the device filter. If None, all devices are included.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filter_selection (str | list[str]): Device filters from BECDeviceFilter and BECReadoutPriority.
|
|
||||||
"""
|
|
||||||
filters = None
|
|
||||||
if isinstance(filter_selection, list):
|
|
||||||
filters = [self._filter_handler.get(entry) for entry in filter_selection]
|
|
||||||
if isinstance(filter_selection, str):
|
|
||||||
filters = [self._filter_handler.get(filter_selection)]
|
|
||||||
if filters is None:
|
|
||||||
return
|
|
||||||
for entry in filters:
|
|
||||||
setattr(self, entry, True)
|
|
||||||
|
|
||||||
def get_device_object(self, device: str) -> object | None:
|
|
||||||
"""
|
|
||||||
Get the device object based on the device name.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
device(str): Device name.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
|
|
||||||
"""
|
|
||||||
self.validate_device(device)
|
|
||||||
dev = getattr(self.dev, device, None)
|
|
||||||
if dev is None:
|
|
||||||
logger.warning(f"Device {device} not found in devicemanager.")
|
|
||||||
return None
|
|
||||||
return dev
|
|
||||||
|
|
||||||
def validate_device(self, device: str | None, raise_on_false: bool = False) -> bool:
|
|
||||||
"""
|
|
||||||
Validate the device if it is present in current BEC instance.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
device(str): Device to validate.
|
|
||||||
raise_on_false(bool): Raise ValueError if device is not found.
|
|
||||||
"""
|
|
||||||
if device in self.dev:
|
|
||||||
return True
|
|
||||||
if raise_on_false is True:
|
|
||||||
raise ValueError(f"Device {device} not found in devicemanager.")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def validate_signal(self, signal: str) -> bool:
|
|
||||||
"""
|
|
||||||
Validate the signal if it is present in the device signals.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
signal(str): Signal to validate.
|
|
||||||
"""
|
|
||||||
for entry in self.signals:
|
|
||||||
if isinstance(entry, tuple):
|
|
||||||
entry = entry[0]
|
|
||||||
if entry == signal:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _process_config_input(self, config: DeviceSignalInputBaseConfig | dict | None):
|
|
||||||
if config is None:
|
|
||||||
return DeviceSignalInputBaseConfig(widget_class=self.__class__.__name__)
|
|
||||||
return DeviceSignalInputBaseConfig.model_validate(config)
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
"""
|
|
||||||
Cleanup the widget.
|
|
||||||
"""
|
|
||||||
self.bec_dispatcher.client.callbacks.remove(self._device_update_register)
|
|
||||||
super().cleanup()
|
|
||||||
@@ -1,32 +1,121 @@
|
|||||||
|
"""Editable combobox for selecting BEC devices."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
|
||||||
from bec_lib.callback_handler import EventType
|
from bec_lib.callback_handler import EventType
|
||||||
from bec_lib.device import ReadoutPriority
|
from bec_lib.device import ComputedSignal, Device, Positioner, ReadoutPriority
|
||||||
from qtpy.QtCore import QSize, Signal, Slot
|
from bec_lib.device import Signal as BECSignal
|
||||||
from qtpy.QtWidgets import QComboBox, QSizePolicy
|
from bec_lib.logger import bec_logger
|
||||||
|
from pydantic import Field, field_validator
|
||||||
|
from qtpy.QtCore import QSize, QStringListModel, Signal, Slot
|
||||||
|
from qtpy.QtWidgets import QComboBox, QCompleter, QSizePolicy
|
||||||
|
|
||||||
from bec_widgets.utils.colors import get_accent_colors
|
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||||
from bec_widgets.utils.error_popups import SafeProperty
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import (
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
BECDeviceFilter,
|
from bec_widgets.utils.filter_io import get_bec_signals_for_classes, replace_combobox_items
|
||||||
DeviceInputBase,
|
|
||||||
DeviceInputConfig,
|
logger = bec_logger.logger
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DeviceComboBox(DeviceInputBase, QComboBox):
|
class BECDeviceFilter(enum.Enum):
|
||||||
|
"""Device class filters accepted by :class:`DeviceComboBox`."""
|
||||||
|
|
||||||
|
DEVICE = "Device"
|
||||||
|
POSITIONER = "Positioner"
|
||||||
|
SIGNAL = "Signal"
|
||||||
|
COMPUTED_SIGNAL = "ComputedSignal"
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceInputConfig(ConnectionConfig):
|
||||||
|
"""Serializable configuration for :class:`DeviceComboBox`.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
device_filter: Enabled device class filters as ``BECDeviceFilter.value`` strings.
|
||||||
|
readout_filter: Enabled readout priority filters as ``ReadoutPriority.value`` strings.
|
||||||
|
devices: Explicit device names shown by the combobox.
|
||||||
|
default: Device selected by default.
|
||||||
|
arg_name: Optional argument name used by scan/input widgets.
|
||||||
|
apply_filter: Whether the combobox should refresh devices from the BEC device manager.
|
||||||
|
signal_class_filter: Signal class names used to restrict listed devices.
|
||||||
|
autocomplete: Whether to use the explicit completer model instead of Qt's default
|
||||||
|
editable-combobox completer.
|
||||||
"""
|
"""
|
||||||
Combobox widget for device input with autocomplete for device names.
|
|
||||||
|
device_filter: list[str] = Field(default_factory=list)
|
||||||
|
readout_filter: list[str] = Field(default_factory=list)
|
||||||
|
devices: list[str] = Field(default_factory=list)
|
||||||
|
default: str | None = None
|
||||||
|
arg_name: str | None = None
|
||||||
|
apply_filter: bool = True
|
||||||
|
signal_class_filter: list[str] = Field(default_factory=list)
|
||||||
|
autocomplete: bool = False
|
||||||
|
|
||||||
|
@field_validator("device_filter")
|
||||||
|
@classmethod
|
||||||
|
def check_device_filter(cls, value):
|
||||||
|
"""Validate configured device class filters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Device class filter values from the persisted widget configuration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated filter values.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If any configured filter is not a valid ``BECDeviceFilter`` value.
|
||||||
|
"""
|
||||||
|
valid_filters = [entry.value for entry in BECDeviceFilter]
|
||||||
|
for device_filter in value:
|
||||||
|
if device_filter not in valid_filters:
|
||||||
|
raise ValueError(
|
||||||
|
f"Device filter {device_filter} is not a valid device filter {valid_filters}."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("readout_filter")
|
||||||
|
@classmethod
|
||||||
|
def check_readout_filter(cls, value):
|
||||||
|
"""Validate configured readout priority filters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Readout priority filter values from the persisted widget configuration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated filter values.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If any configured filter is not a valid ``ReadoutPriority`` value.
|
||||||
|
"""
|
||||||
|
valid_filters = [entry.value for entry in ReadoutPriority]
|
||||||
|
for readout_filter in value:
|
||||||
|
if readout_filter not in valid_filters:
|
||||||
|
raise ValueError(
|
||||||
|
f"Readout filter {readout_filter} is not a valid readout filter {valid_filters}."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceComboBox(BECWidget, QComboBox):
|
||||||
|
"""Editable combobox for selecting a BEC device.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
parent: Parent widget.
|
parent: Optional parent widget.
|
||||||
client: BEC client object.
|
client: Optional BEC client object.
|
||||||
config: Device input configuration.
|
config: Device input configuration as a ``DeviceInputConfig`` instance or dictionary.
|
||||||
gui_id: GUI ID.
|
gui_id: Optional GUI identifier.
|
||||||
device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
|
device_filter: Device class filter or filters from ``BECDeviceFilter``.
|
||||||
readout_priority_filter: Readout priority filter, name of the readout priority class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
|
readout_priority_filter: Readout priority filter or filters from ``ReadoutPriority``.
|
||||||
available_devices: List of available devices, if passed, it sets apply filters to false and device/readout priority filters will not be applied.
|
available_devices: Explicit device names to show. Passing this disables automatic
|
||||||
default: Default device name.
|
BEC filtering.
|
||||||
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
|
default: Device name selected during initialization.
|
||||||
signal_class_filter: List of signal classes to filter the devices by. Only devices with signals of these classes will be shown.
|
arg_name: Optional argument name used by scan/input widgets.
|
||||||
|
signal_class_filter: Signal class names used to restrict listed devices.
|
||||||
|
autocomplete: If True, use the explicit line-edit style completer. If False, keep
|
||||||
|
Qt's default editable-combobox completion behavior.
|
||||||
|
**kwargs: Additional keyword arguments passed to ``BECWidget``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ICON_NAME = "list_alt"
|
ICON_NAME = "list_alt"
|
||||||
@@ -37,62 +126,96 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
|||||||
device_reset = Signal()
|
device_reset = Signal()
|
||||||
device_config_update = Signal()
|
device_config_update = Signal()
|
||||||
|
|
||||||
|
_device_handler = {
|
||||||
|
BECDeviceFilter.DEVICE: Device,
|
||||||
|
BECDeviceFilter.POSITIONER: Positioner,
|
||||||
|
BECDeviceFilter.SIGNAL: BECSignal,
|
||||||
|
BECDeviceFilter.COMPUTED_SIGNAL: ComputedSignal,
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
parent=None,
|
parent=None,
|
||||||
client=None,
|
client=None,
|
||||||
config: DeviceInputConfig = None,
|
config: DeviceInputConfig | dict | None = None,
|
||||||
gui_id: str | None = None,
|
gui_id: str | None = None,
|
||||||
device_filter: BECDeviceFilter | list[BECDeviceFilter] | None = None,
|
device_filter: BECDeviceFilter | str | list[BECDeviceFilter | str] | None = None,
|
||||||
readout_priority_filter: (
|
readout_priority_filter: str | ReadoutPriority | list[str | ReadoutPriority] | None = None,
|
||||||
str | ReadoutPriority | list[str] | list[ReadoutPriority] | None
|
|
||||||
) = None,
|
|
||||||
available_devices: list[str] | None = None,
|
available_devices: list[str] | None = None,
|
||||||
default: str | None = None,
|
default: str | None = None,
|
||||||
arg_name: str | None = None,
|
arg_name: str | None = None,
|
||||||
signal_class_filter: list[str] | None = None,
|
signal_class_filter: list[str] | None = None,
|
||||||
|
autocomplete: bool | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
self.config = self._process_config(config)
|
||||||
|
super().__init__(
|
||||||
|
parent=parent,
|
||||||
|
client=client,
|
||||||
|
config=self.config,
|
||||||
|
gui_id=gui_id,
|
||||||
|
theme_update=True,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
self.get_bec_shortcuts()
|
||||||
|
|
||||||
|
self._device_filter: list[BECDeviceFilter] = []
|
||||||
|
self._readout_filter: list[ReadoutPriority] = []
|
||||||
|
self._devices: list[str] = []
|
||||||
|
self._callback_id = None
|
||||||
|
self._is_valid_input = False
|
||||||
|
self._set_first_element_as_empty = False
|
||||||
|
self._completer_model = QStringListModel(self)
|
||||||
|
|
||||||
|
self.setEditable(True)
|
||||||
|
self.setInsertPolicy(QComboBox.NoInsert)
|
||||||
|
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||||
|
self.setMinimumSize(QSize(100, 0))
|
||||||
|
|
||||||
if arg_name is not None:
|
if arg_name is not None:
|
||||||
self.config.arg_name = arg_name
|
self.config.arg_name = arg_name
|
||||||
self.arg_name = arg_name
|
self.arg_name = arg_name
|
||||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
|
||||||
self.setMinimumSize(QSize(100, 0))
|
|
||||||
self._callback_id = None
|
|
||||||
self._is_valid_input = False
|
|
||||||
self._accent_colors = get_accent_colors()
|
|
||||||
self._set_first_element_as_empty = False
|
|
||||||
|
|
||||||
# We do not consider the config that is passed here, this produced problems
|
if available_devices is None and self.config.devices:
|
||||||
# with QtDesigner, since config and input arguments may differ and resolve properly
|
available_devices = self.config.devices
|
||||||
# Implementing this logic and config recoverage is postponed.
|
if device_filter is None and self.config.device_filter:
|
||||||
# Set available devices if passed
|
device_filter = self.config.device_filter
|
||||||
|
if readout_priority_filter is None and self.config.readout_filter:
|
||||||
|
readout_priority_filter = self.config.readout_filter
|
||||||
|
if signal_class_filter is None and self.config.signal_class_filter:
|
||||||
|
signal_class_filter = self.config.signal_class_filter
|
||||||
|
if default is None and self.config.default:
|
||||||
|
default = self.config.default
|
||||||
|
if autocomplete is not None:
|
||||||
|
self.config.autocomplete = autocomplete
|
||||||
|
if self.config.autocomplete:
|
||||||
|
self.autocomplete = True
|
||||||
|
|
||||||
if available_devices is not None:
|
if available_devices is not None:
|
||||||
self.set_available_devices(available_devices)
|
self.set_available_devices(available_devices)
|
||||||
# Set readout priority filter default is all
|
|
||||||
if readout_priority_filter is not None:
|
self.set_readout_priority_filter(
|
||||||
self.set_readout_priority_filter(readout_priority_filter)
|
readout_priority_filter
|
||||||
else:
|
or [
|
||||||
self.set_readout_priority_filter(
|
ReadoutPriority.MONITORED,
|
||||||
[
|
ReadoutPriority.BASELINE,
|
||||||
ReadoutPriority.MONITORED,
|
ReadoutPriority.ASYNC,
|
||||||
ReadoutPriority.BASELINE,
|
ReadoutPriority.CONTINUOUS,
|
||||||
ReadoutPriority.ASYNC,
|
ReadoutPriority.ON_REQUEST,
|
||||||
ReadoutPriority.CONTINUOUS,
|
]
|
||||||
ReadoutPriority.ON_REQUEST,
|
)
|
||||||
]
|
|
||||||
)
|
|
||||||
# Device filter default is None
|
|
||||||
if device_filter is not None:
|
if device_filter is not None:
|
||||||
self.set_device_filter(device_filter)
|
self.set_device_filter(device_filter)
|
||||||
|
|
||||||
if signal_class_filter is not None:
|
if signal_class_filter is not None:
|
||||||
self.signal_class_filter = signal_class_filter
|
self.signal_class_filter = signal_class_filter
|
||||||
|
|
||||||
# Set default device if passed
|
|
||||||
if default is not None:
|
if default is not None:
|
||||||
self.set_device(default)
|
self.set_device(default)
|
||||||
|
else:
|
||||||
|
self.setCurrentText("")
|
||||||
|
|
||||||
self._callback_id = self.bec_dispatcher.client.callbacks.register(
|
self._callback_id = self.bec_dispatcher.client.callbacks.register(
|
||||||
EventType.DEVICE_UPDATE, self.on_device_update
|
EventType.DEVICE_UPDATE, self.on_device_update
|
||||||
)
|
)
|
||||||
@@ -100,38 +223,271 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
|||||||
self.currentTextChanged.connect(self.check_validity)
|
self.currentTextChanged.connect(self.check_validity)
|
||||||
self.check_validity(self.currentText())
|
self.check_validity(self.currentText())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _process_config(config: DeviceInputConfig | dict | None) -> DeviceInputConfig:
|
||||||
|
"""Normalize user-provided configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Existing configuration, configuration dictionary, or None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A validated ``DeviceInputConfig`` instance.
|
||||||
|
"""
|
||||||
|
if config is None:
|
||||||
|
return DeviceInputConfig(widget_class="DeviceComboBox")
|
||||||
|
return DeviceInputConfig.model_validate(config)
|
||||||
|
|
||||||
|
@SafeSlot(str)
|
||||||
|
def set_device(self, device: str):
|
||||||
|
"""Set the current device if it is valid for the current filters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device: Device name to select.
|
||||||
|
"""
|
||||||
|
if self.validate_device(device):
|
||||||
|
self.setCurrentText(device)
|
||||||
|
self.config.default = device
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Device {device} is not in the filtered selection of {self}: {self.devices}."
|
||||||
|
)
|
||||||
|
|
||||||
|
@SafeSlot()
|
||||||
|
def update_devices_from_filters(self):
|
||||||
|
"""Refresh the available device list from current device/readout/signal filters."""
|
||||||
|
self.config.device_filter = [entry.value for entry in self.device_filter]
|
||||||
|
self.config.readout_filter = [entry.value for entry in self.readout_filter]
|
||||||
|
self.config.signal_class_filter = self.signal_class_filter
|
||||||
|
if not self.apply_filter:
|
||||||
|
return
|
||||||
|
|
||||||
|
devices = self._filter_devices_by_signal_class(self.dev.enabled_devices)
|
||||||
|
devices = [device for device in devices if self._check_device_filter(device)]
|
||||||
|
devices = [device for device in devices if self._check_readout_filter(device)]
|
||||||
|
self.devices = [device.name for device in devices]
|
||||||
|
|
||||||
|
@SafeSlot(list)
|
||||||
|
def set_available_devices(self, devices: list[str]):
|
||||||
|
"""Use an explicit device list and disable automatic BEC filtering.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
devices: Device names to show in the combobox.
|
||||||
|
"""
|
||||||
|
self.apply_filter = False
|
||||||
|
self.devices = devices
|
||||||
|
|
||||||
|
@SafeProperty("QStringList")
|
||||||
|
def devices(self) -> list[str]:
|
||||||
|
"""Devices available after filtering."""
|
||||||
|
return self._devices
|
||||||
|
|
||||||
|
@devices.setter
|
||||||
|
def devices(self, value: list[str]):
|
||||||
|
self._devices = value
|
||||||
|
self.config.devices = value
|
||||||
|
self._replace_items(value)
|
||||||
|
|
||||||
|
@SafeProperty(str)
|
||||||
|
def default(self):
|
||||||
|
"""Default selected device."""
|
||||||
|
return self.config.default
|
||||||
|
|
||||||
|
@default.setter
|
||||||
|
def default(self, value: str):
|
||||||
|
self.set_device(value)
|
||||||
|
|
||||||
|
@SafeProperty(bool)
|
||||||
|
def apply_filter(self):
|
||||||
|
"""Whether BEC filters are applied to the device list."""
|
||||||
|
return self.config.apply_filter
|
||||||
|
|
||||||
|
@apply_filter.setter
|
||||||
|
def apply_filter(self, value: bool):
|
||||||
|
self.config.apply_filter = value
|
||||||
|
if value:
|
||||||
|
self.update_devices_from_filters()
|
||||||
|
|
||||||
|
@SafeProperty("QStringList")
|
||||||
|
def signal_class_filter(self) -> list[str]:
|
||||||
|
"""Signal class names used to restrict devices."""
|
||||||
|
return self.config.signal_class_filter
|
||||||
|
|
||||||
|
@signal_class_filter.setter
|
||||||
|
def signal_class_filter(self, value: list[str] | None):
|
||||||
|
self.config.signal_class_filter = value or []
|
||||||
|
self.update_devices_from_filters()
|
||||||
|
|
||||||
|
@SafeProperty(bool)
|
||||||
|
def filter_to_device(self):
|
||||||
|
"""Include generic Device objects."""
|
||||||
|
return BECDeviceFilter.DEVICE in self.device_filter
|
||||||
|
|
||||||
|
@filter_to_device.setter
|
||||||
|
def filter_to_device(self, value: bool):
|
||||||
|
self._set_device_filter_enabled(BECDeviceFilter.DEVICE, value)
|
||||||
|
|
||||||
|
@SafeProperty(bool)
|
||||||
|
def filter_to_positioner(self):
|
||||||
|
"""Include Positioner devices."""
|
||||||
|
return BECDeviceFilter.POSITIONER in self.device_filter
|
||||||
|
|
||||||
|
@filter_to_positioner.setter
|
||||||
|
def filter_to_positioner(self, value: bool):
|
||||||
|
self._set_device_filter_enabled(BECDeviceFilter.POSITIONER, value)
|
||||||
|
|
||||||
|
@SafeProperty(bool)
|
||||||
|
def filter_to_signal(self):
|
||||||
|
"""Include Signal devices."""
|
||||||
|
return BECDeviceFilter.SIGNAL in self.device_filter
|
||||||
|
|
||||||
|
@filter_to_signal.setter
|
||||||
|
def filter_to_signal(self, value: bool):
|
||||||
|
self._set_device_filter_enabled(BECDeviceFilter.SIGNAL, value)
|
||||||
|
|
||||||
|
@SafeProperty(bool)
|
||||||
|
def filter_to_computed_signal(self):
|
||||||
|
"""Include ComputedSignal devices."""
|
||||||
|
return BECDeviceFilter.COMPUTED_SIGNAL in self.device_filter
|
||||||
|
|
||||||
|
@filter_to_computed_signal.setter
|
||||||
|
def filter_to_computed_signal(self, value: bool):
|
||||||
|
self._set_device_filter_enabled(BECDeviceFilter.COMPUTED_SIGNAL, value)
|
||||||
|
|
||||||
|
@SafeProperty(bool)
|
||||||
|
def readout_monitored(self):
|
||||||
|
"""Include monitored devices."""
|
||||||
|
return ReadoutPriority.MONITORED in self.readout_filter
|
||||||
|
|
||||||
|
@readout_monitored.setter
|
||||||
|
def readout_monitored(self, value: bool):
|
||||||
|
self._set_readout_filter_enabled(ReadoutPriority.MONITORED, value)
|
||||||
|
|
||||||
|
@SafeProperty(bool)
|
||||||
|
def readout_baseline(self):
|
||||||
|
"""Include baseline devices."""
|
||||||
|
return ReadoutPriority.BASELINE in self.readout_filter
|
||||||
|
|
||||||
|
@readout_baseline.setter
|
||||||
|
def readout_baseline(self, value: bool):
|
||||||
|
self._set_readout_filter_enabled(ReadoutPriority.BASELINE, value)
|
||||||
|
|
||||||
|
@SafeProperty(bool)
|
||||||
|
def readout_async(self):
|
||||||
|
"""Include async devices."""
|
||||||
|
return ReadoutPriority.ASYNC in self.readout_filter
|
||||||
|
|
||||||
|
@readout_async.setter
|
||||||
|
def readout_async(self, value: bool):
|
||||||
|
self._set_readout_filter_enabled(ReadoutPriority.ASYNC, value)
|
||||||
|
|
||||||
|
@SafeProperty(bool)
|
||||||
|
def readout_continuous(self):
|
||||||
|
"""Include continuous devices."""
|
||||||
|
return ReadoutPriority.CONTINUOUS in self.readout_filter
|
||||||
|
|
||||||
|
@readout_continuous.setter
|
||||||
|
def readout_continuous(self, value: bool):
|
||||||
|
self._set_readout_filter_enabled(ReadoutPriority.CONTINUOUS, value)
|
||||||
|
|
||||||
|
@SafeProperty(bool)
|
||||||
|
def readout_on_request(self):
|
||||||
|
"""Include on-request devices."""
|
||||||
|
return ReadoutPriority.ON_REQUEST in self.readout_filter
|
||||||
|
|
||||||
|
@readout_on_request.setter
|
||||||
|
def readout_on_request(self, value: bool):
|
||||||
|
self._set_readout_filter_enabled(ReadoutPriority.ON_REQUEST, value)
|
||||||
|
|
||||||
@SafeProperty(bool)
|
@SafeProperty(bool)
|
||||||
def set_first_element_as_empty(self) -> bool:
|
def set_first_element_as_empty(self) -> bool:
|
||||||
"""
|
"""Whether an empty choice is inserted as the first item."""
|
||||||
Whether the first element in the combobox should be empty.
|
|
||||||
This is useful to allow the user to select a device from the list.
|
|
||||||
"""
|
|
||||||
return self._set_first_element_as_empty
|
return self._set_first_element_as_empty
|
||||||
|
|
||||||
@set_first_element_as_empty.setter
|
@set_first_element_as_empty.setter
|
||||||
def set_first_element_as_empty(self, value: bool) -> None:
|
def set_first_element_as_empty(self, value: bool) -> None:
|
||||||
"""
|
self._set_first_element_as_empty = value
|
||||||
Set whether the first element in the combobox should be empty.
|
current_text = self.currentText()
|
||||||
This is useful to allow the user to select a device from the list.
|
if value:
|
||||||
|
if self.count() == 0 or self.itemText(0) != "":
|
||||||
|
self.insertItem(0, "")
|
||||||
|
self.setCurrentIndex(0)
|
||||||
|
elif self.count() > 0 and self.itemText(0) == "":
|
||||||
|
self.removeItem(0)
|
||||||
|
if not current_text:
|
||||||
|
self.setCurrentText("")
|
||||||
|
|
||||||
|
@SafeProperty(bool)
|
||||||
|
def autocomplete(self) -> bool:
|
||||||
|
"""Whether autocomplete suggestions are enabled while editing."""
|
||||||
|
return self.config.autocomplete
|
||||||
|
|
||||||
|
@autocomplete.setter
|
||||||
|
def autocomplete(self, value: bool) -> None:
|
||||||
|
self.config.autocomplete = value
|
||||||
|
if value:
|
||||||
|
self.setCompleter(QCompleter(self._completer_model, self))
|
||||||
|
else:
|
||||||
|
self.setCompleter(QCompleter(self.model(), self))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_filter(self) -> list[BECDeviceFilter]:
|
||||||
|
"""Device class filters."""
|
||||||
|
return self._device_filter
|
||||||
|
|
||||||
|
@property
|
||||||
|
def readout_filter(self) -> list[ReadoutPriority]:
|
||||||
|
"""Readout priority filters."""
|
||||||
|
return self._readout_filter
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_valid_input(self) -> bool:
|
||||||
|
"""Whether the current text represents a valid device selection."""
|
||||||
|
return self._is_valid_input
|
||||||
|
|
||||||
|
def setEnabled(self, enabled: bool) -> None: # noqa: N802
|
||||||
|
super().setEnabled(enabled)
|
||||||
|
self._update_validity_style(self._is_valid_input)
|
||||||
|
|
||||||
|
def set_device_filter(
|
||||||
|
self, filter_selection: BECDeviceFilter | str | list[BECDeviceFilter | str]
|
||||||
|
):
|
||||||
|
"""Enable one or more device class filters.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
value (bool): True if the first element should be empty, False otherwise.
|
filter_selection: Filter or filters to enable. Strings must match
|
||||||
|
``BECDeviceFilter.value``.
|
||||||
"""
|
"""
|
||||||
self._set_first_element_as_empty = value
|
for device_filter in self._as_list(filter_selection):
|
||||||
if self._set_first_element_as_empty:
|
normalized = self._normalize_device_filter(device_filter)
|
||||||
self.insertItem(0, "")
|
if normalized is None:
|
||||||
self.setCurrentIndex(0)
|
logger.warning(f"Device filter {device_filter} is not in the device filter list.")
|
||||||
else:
|
continue
|
||||||
if self.count() > 0 and self.itemText(0) == "":
|
self._set_device_filter_enabled(normalized, True)
|
||||||
self.removeItem(0)
|
|
||||||
|
def set_readout_priority_filter(
|
||||||
|
self, filter_selection: ReadoutPriority | str | list[ReadoutPriority | str]
|
||||||
|
):
|
||||||
|
"""Enable one or more readout priority filters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filter_selection: Readout priority filter or filters to enable. Strings must match
|
||||||
|
``ReadoutPriority.value``.
|
||||||
|
"""
|
||||||
|
for readout_filter in self._as_list(filter_selection):
|
||||||
|
normalized = self._normalize_readout_filter(readout_filter)
|
||||||
|
if normalized is None:
|
||||||
|
logger.warning(
|
||||||
|
f"Readout priority filter {readout_filter} is not in the readout priority list."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
self._set_readout_filter_enabled(normalized, True)
|
||||||
|
|
||||||
def on_device_update(self, action: str, content: dict) -> None:
|
def on_device_update(self, action: str, content: dict) -> None:
|
||||||
"""
|
"""Refresh filters when the BEC device configuration changes.
|
||||||
Callback for device update events. Triggers the device_update signal.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
action (str): The action that triggered the event.
|
action: Device update action emitted by BEC.
|
||||||
content (dict): The content of the config update.
|
content: Device update payload. Currently unused.
|
||||||
"""
|
"""
|
||||||
if action in ["add", "remove", "reload"]:
|
if action in ["add", "remove", "reload"]:
|
||||||
self.device_config_update.emit()
|
self.device_config_update.emit()
|
||||||
@@ -143,57 +499,134 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
|||||||
super().cleanup()
|
super().cleanup()
|
||||||
|
|
||||||
def get_current_device(self) -> object:
|
def get_current_device(self) -> object:
|
||||||
"""
|
"""Return the current BEC device object.
|
||||||
Get the current device object based on the current value.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
|
Device object for the current combobox text.
|
||||||
"""
|
"""
|
||||||
dev_name = self.currentText()
|
return self.get_device_object(self._device_name_from_text(self.currentText()))
|
||||||
return self.get_device_object(dev_name)
|
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
def check_validity(self, input_text: str) -> None:
|
def check_validity(self, input_text: str) -> None:
|
||||||
|
"""Validate current text and update visual state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_text: Current combobox text.
|
||||||
"""
|
"""
|
||||||
Check if the current value is a valid device name.
|
if self.validate_device(input_text):
|
||||||
"""
|
|
||||||
if self.validate_device(input_text) is True:
|
|
||||||
self._is_valid_input = True
|
self._is_valid_input = True
|
||||||
self.device_selected.emit(input_text)
|
self.device_selected.emit(input_text)
|
||||||
self.setStyleSheet("border: 1px solid transparent;")
|
|
||||||
else:
|
else:
|
||||||
self._is_valid_input = False
|
self._is_valid_input = False
|
||||||
self.device_reset.emit()
|
self.device_reset.emit()
|
||||||
if self.isEnabled():
|
self._update_validity_style(self._is_valid_input)
|
||||||
self.setStyleSheet("border: 1px solid red;")
|
|
||||||
|
|
||||||
def validate_device(self, device: str) -> bool: # type: ignore[override]
|
def validate_device(self, device: str | None) -> bool:
|
||||||
"""
|
"""Validate a device against the current filtered device selection.
|
||||||
Extend validation so that preview‑signal pseudo‑devices (labels like
|
|
||||||
``"eiger_preview"``) are accepted as valid choices.
|
|
||||||
|
|
||||||
The validation run only on device not on the preview‑signal.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
device: The text currently entered/selected.
|
device: Device name or displayed device text to validate.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if the device is a genuine BEC device *or* one of the
|
True if the device exists in the current BEC device manager and is present in the
|
||||||
whitelisted preview‑signal entries.
|
filtered combobox list.
|
||||||
"""
|
"""
|
||||||
idx = self.findText(device)
|
if not device:
|
||||||
if idx >= 0 and isinstance(self.itemData(idx), tuple):
|
return False
|
||||||
device = self.itemData(idx)[0] # type: ignore[assignment]
|
device_name = self._device_name_from_text(device)
|
||||||
return super().validate_device(device)
|
all_devices = [dev.name for dev in self.dev.enabled_devices]
|
||||||
|
return device_name in self.devices and device_name in all_devices
|
||||||
|
|
||||||
@property
|
def get_device_object(self, device: str) -> object:
|
||||||
def is_valid_input(self) -> bool:
|
"""Return a device object by name.
|
||||||
"""Whether the current text represents a valid device selection."""
|
|
||||||
return self._is_valid_input
|
Args:
|
||||||
|
device: Device name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BEC device object.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the device is not available in the device manager.
|
||||||
|
"""
|
||||||
|
dev = getattr(self.dev, device, None)
|
||||||
|
if dev is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Device {device} is not found in the device manager {self.dev} as enabled device."
|
||||||
|
)
|
||||||
|
return dev
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_list(value):
|
||||||
|
return value if isinstance(value, list) else [value]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_device_filter(value: BECDeviceFilter | str) -> BECDeviceFilter | None:
|
||||||
|
if isinstance(value, BECDeviceFilter):
|
||||||
|
return value
|
||||||
|
return BECDeviceFilter._value2member_map_.get(value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_readout_filter(value: ReadoutPriority | str) -> ReadoutPriority | None:
|
||||||
|
if isinstance(value, ReadoutPriority):
|
||||||
|
return value
|
||||||
|
return ReadoutPriority._value2member_map_.get(value)
|
||||||
|
|
||||||
|
def _set_device_filter_enabled(self, device_filter: BECDeviceFilter, enabled: bool):
|
||||||
|
if enabled and device_filter not in self._device_filter:
|
||||||
|
self._device_filter.append(device_filter)
|
||||||
|
elif not enabled and device_filter in self._device_filter:
|
||||||
|
self._device_filter.remove(device_filter)
|
||||||
|
self.update_devices_from_filters()
|
||||||
|
|
||||||
|
def _set_readout_filter_enabled(self, readout_filter: ReadoutPriority, enabled: bool):
|
||||||
|
if enabled and readout_filter not in self._readout_filter:
|
||||||
|
self._readout_filter.append(readout_filter)
|
||||||
|
elif not enabled and readout_filter in self._readout_filter:
|
||||||
|
self._readout_filter.remove(readout_filter)
|
||||||
|
self.update_devices_from_filters()
|
||||||
|
|
||||||
|
def _check_device_filter(
|
||||||
|
self, device: Device | BECSignal | ComputedSignal | Positioner
|
||||||
|
) -> bool:
|
||||||
|
if not self.device_filter:
|
||||||
|
return True
|
||||||
|
return all(isinstance(device, self._device_handler[entry]) for entry in self.device_filter)
|
||||||
|
|
||||||
|
def _check_readout_filter(
|
||||||
|
self, device: Device | BECSignal | ComputedSignal | Positioner
|
||||||
|
) -> bool:
|
||||||
|
return device.readout_priority in self.readout_filter
|
||||||
|
|
||||||
|
def _update_validity_style(self, is_valid: bool) -> None:
|
||||||
|
border_color = "transparent" if is_valid or not self.isEnabled() else "red"
|
||||||
|
self.setStyleSheet(f"border: 1px solid {border_color};")
|
||||||
|
|
||||||
|
def _filter_devices_by_signal_class(
|
||||||
|
self, devices: list[Device | BECSignal | ComputedSignal | Positioner]
|
||||||
|
) -> list[Device | BECSignal | ComputedSignal | Positioner]:
|
||||||
|
if not self.config.signal_class_filter:
|
||||||
|
return devices
|
||||||
|
signals = get_bec_signals_for_classes(
|
||||||
|
client=self.client, signal_class_filter=self.config.signal_class_filter
|
||||||
|
)
|
||||||
|
allowed_devices = {device_name for device_name, _, _ in signals}
|
||||||
|
return [device for device in devices if device.name in allowed_devices]
|
||||||
|
|
||||||
|
def _replace_items(self, devices: list[str]):
|
||||||
|
items = [""] + devices if self._set_first_element_as_empty else devices
|
||||||
|
replace_combobox_items(self, items, preserve_current_text=True, block_signals=True)
|
||||||
|
self._completer_model.setStringList(devices)
|
||||||
|
self.check_validity(self.currentText())
|
||||||
|
|
||||||
|
def _device_name_from_text(self, text: str) -> str:
|
||||||
|
index = self.findText(text)
|
||||||
|
if index >= 0 and isinstance(self.itemData(index), tuple):
|
||||||
|
return self.itemData(index)[0]
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
# pylint: disable=import-outside-toplevel
|
|
||||||
from qtpy.QtWidgets import (
|
from qtpy.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
@@ -235,10 +668,7 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
|
|
||||||
def _apply_filters():
|
def _apply_filters():
|
||||||
raw = class_input.text().strip()
|
raw = class_input.text().strip()
|
||||||
if raw:
|
combo.signal_class_filter = [entry.strip() for entry in raw.split(",") if entry.strip()]
|
||||||
combo.signal_class_filter = [entry.strip() for entry in raw.split(",") if entry.strip()]
|
|
||||||
else:
|
|
||||||
combo.signal_class_filter = []
|
|
||||||
combo.filter_to_device = filter_device.isChecked()
|
combo.filter_to_device = filter_device.isChecked()
|
||||||
combo.filter_to_positioner = filter_positioner.isChecked()
|
combo.filter_to_positioner = filter_positioner.isChecked()
|
||||||
combo.filter_to_signal = filter_signal.isChecked()
|
combo.filter_to_signal = filter_signal.isChecked()
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
from bec_lib.callback_handler import EventType
|
|
||||||
from bec_lib.device import ReadoutPriority
|
|
||||||
from bec_lib.logger import bec_logger
|
|
||||||
from qtpy.QtCore import QSize, Signal, Slot
|
|
||||||
from qtpy.QtGui import QPainter, QPaintEvent, QPen
|
|
||||||
from qtpy.QtWidgets import QApplication, QCompleter, QLineEdit, QSizePolicy
|
|
||||||
|
|
||||||
from bec_widgets.utils.colors import get_accent_colors
|
|
||||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import (
|
|
||||||
BECDeviceFilter,
|
|
||||||
DeviceInputBase,
|
|
||||||
DeviceInputConfig,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = bec_logger.logger
|
|
||||||
|
|
||||||
|
|
||||||
class DeviceLineEdit(DeviceInputBase, QLineEdit):
|
|
||||||
"""
|
|
||||||
Line edit widget for device input with autocomplete for device names.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
parent: Parent widget.
|
|
||||||
client: BEC client object.
|
|
||||||
config: Device input configuration.
|
|
||||||
gui_id: GUI ID.
|
|
||||||
device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
|
|
||||||
readout_priority_filter: Readout priority filter, name of the readout priority class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
|
|
||||||
available_devices: List of available devices, if passed, it sets apply filters to false and device/readout priority filters will not be applied.
|
|
||||||
default: Default device name.
|
|
||||||
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
|
|
||||||
"""
|
|
||||||
|
|
||||||
device_selected = Signal(str)
|
|
||||||
device_config_update = Signal()
|
|
||||||
|
|
||||||
PLUGIN = True
|
|
||||||
RPC = False
|
|
||||||
ICON_NAME = "edit_note"
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
parent=None,
|
|
||||||
client=None,
|
|
||||||
config: DeviceInputConfig = None,
|
|
||||||
gui_id: str | None = None,
|
|
||||||
device_filter: BECDeviceFilter | list[BECDeviceFilter] | None = None,
|
|
||||||
readout_priority_filter: (
|
|
||||||
str | ReadoutPriority | list[str] | list[ReadoutPriority] | None
|
|
||||||
) = None,
|
|
||||||
available_devices: list[str] | None = None,
|
|
||||||
default: str | None = None,
|
|
||||||
arg_name: str | None = None,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
self._callback_id = None
|
|
||||||
self.__is_valid_input = False
|
|
||||||
self._accent_colors = get_accent_colors()
|
|
||||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
|
||||||
self.completer = QCompleter(self)
|
|
||||||
self.setCompleter(self.completer)
|
|
||||||
|
|
||||||
if arg_name is not None:
|
|
||||||
self.config.arg_name = arg_name
|
|
||||||
self.arg_name = arg_name
|
|
||||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
|
||||||
self.setMinimumSize(QSize(100, 0))
|
|
||||||
|
|
||||||
# We do not consider the config that is passed here, this produced problems
|
|
||||||
# with QtDesigner, since config and input arguments may differ and resolve properly
|
|
||||||
# Implementing this logic and config recoverage is postponed.
|
|
||||||
# Set available devices if passed
|
|
||||||
if available_devices is not None:
|
|
||||||
self.set_available_devices(available_devices)
|
|
||||||
# Set readout priority filter default is all
|
|
||||||
if readout_priority_filter is not None:
|
|
||||||
self.set_readout_priority_filter(readout_priority_filter)
|
|
||||||
else:
|
|
||||||
self.set_readout_priority_filter(
|
|
||||||
[
|
|
||||||
ReadoutPriority.MONITORED,
|
|
||||||
ReadoutPriority.BASELINE,
|
|
||||||
ReadoutPriority.ASYNC,
|
|
||||||
ReadoutPriority.CONTINUOUS,
|
|
||||||
ReadoutPriority.ON_REQUEST,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
# Device filter default is None
|
|
||||||
if device_filter is not None:
|
|
||||||
self.set_device_filter(device_filter)
|
|
||||||
# Set default device if passed
|
|
||||||
if default is not None:
|
|
||||||
self.set_device(default)
|
|
||||||
self._callback_id = self.bec_dispatcher.client.callbacks.register(
|
|
||||||
EventType.DEVICE_UPDATE, self.on_device_update
|
|
||||||
)
|
|
||||||
self.device_config_update.connect(self.update_devices_from_filters)
|
|
||||||
self.textChanged.connect(self.check_validity)
|
|
||||||
self.check_validity(self.text())
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _is_valid_input(self) -> bool:
|
|
||||||
"""
|
|
||||||
Check if the current value is a valid device name.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if the current value is a valid device name, False otherwise.
|
|
||||||
"""
|
|
||||||
return self.__is_valid_input
|
|
||||||
|
|
||||||
@_is_valid_input.setter
|
|
||||||
def _is_valid_input(self, value: bool) -> None:
|
|
||||||
self.__is_valid_input = value
|
|
||||||
|
|
||||||
def on_device_update(self, action: str, content: dict) -> None:
|
|
||||||
"""
|
|
||||||
Callback for device update events. Triggers the device_update signal.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
action (str): The action that triggered the event.
|
|
||||||
content (dict): The content of the config update.
|
|
||||||
"""
|
|
||||||
if action in ["add", "remove", "reload"]:
|
|
||||||
self.device_config_update.emit()
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
"""Cleanup the widget."""
|
|
||||||
if self._callback_id is not None:
|
|
||||||
self.bec_dispatcher.client.callbacks.remove(self._callback_id)
|
|
||||||
super().cleanup()
|
|
||||||
|
|
||||||
def get_current_device(self) -> object:
|
|
||||||
"""
|
|
||||||
Get the current device object based on the current value.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
|
|
||||||
"""
|
|
||||||
dev_name = self.text()
|
|
||||||
return self.get_device_object(dev_name)
|
|
||||||
|
|
||||||
def paintEvent(self, event: QPaintEvent) -> None:
|
|
||||||
"""Extend the paint event to set the border color based on the validity of the input.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event (PySide6.QtGui.QPaintEvent) : Paint event.
|
|
||||||
"""
|
|
||||||
# logger.info(f"Received paint event: {event} in {self.__class__}")
|
|
||||||
super().paintEvent(event)
|
|
||||||
|
|
||||||
if self._is_valid_input is False and self.isEnabled() is True:
|
|
||||||
painter = QPainter(self)
|
|
||||||
pen = QPen()
|
|
||||||
pen.setWidth(2)
|
|
||||||
pen.setColor(self._accent_colors.emergency)
|
|
||||||
painter.setPen(pen)
|
|
||||||
painter.drawRect(self.rect().adjusted(1, 1, -1, -1))
|
|
||||||
painter.end()
|
|
||||||
|
|
||||||
@Slot(str)
|
|
||||||
def check_validity(self, input_text: str) -> None:
|
|
||||||
"""
|
|
||||||
Check if the current value is a valid device name.
|
|
||||||
"""
|
|
||||||
if self.validate_device(input_text) is True:
|
|
||||||
self._is_valid_input = True
|
|
||||||
self.device_selected.emit(input_text)
|
|
||||||
else:
|
|
||||||
self._is_valid_input = False
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
# pylint: disable=import-outside-toplevel
|
|
||||||
from qtpy.QtWidgets import QVBoxLayout, QWidget
|
|
||||||
|
|
||||||
from bec_widgets.utils.colors import apply_theme
|
|
||||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import (
|
|
||||||
SignalComboBox,
|
|
||||||
)
|
|
||||||
|
|
||||||
app = QApplication([])
|
|
||||||
apply_theme("dark")
|
|
||||||
widget = QWidget()
|
|
||||||
widget.setFixedSize(200, 200)
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
widget.setLayout(layout)
|
|
||||||
line_edit = DeviceLineEdit()
|
|
||||||
line_edit.filter_to_positioner = True
|
|
||||||
signal_line_edit = SignalComboBox()
|
|
||||||
line_edit.textChanged.connect(signal_line_edit.set_device)
|
|
||||||
line_edit.set_available_devices(["samx", "samy", "samz"])
|
|
||||||
line_edit.set_device("samx")
|
|
||||||
layout.addWidget(line_edit)
|
|
||||||
layout.addWidget(signal_line_edit)
|
|
||||||
widget.show()
|
|
||||||
app.exec_()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{'files': ['device_line_edit.py']}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# Copyright (C) 2022 The Qt Company Ltd.
|
|
||||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
|
||||||
|
|
||||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
|
||||||
from qtpy.QtWidgets import QWidget
|
|
||||||
|
|
||||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
|
||||||
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
|
|
||||||
DeviceLineEdit,
|
|
||||||
)
|
|
||||||
|
|
||||||
DOM_XML = """
|
|
||||||
<ui language='c++'>
|
|
||||||
<widget class='DeviceLineEdit' name='device_line_edit'>
|
|
||||||
</widget>
|
|
||||||
</ui>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class DeviceLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self._form_editor = None
|
|
||||||
|
|
||||||
def createWidget(self, parent):
|
|
||||||
if parent is None:
|
|
||||||
return QWidget()
|
|
||||||
t = DeviceLineEdit(parent)
|
|
||||||
return t
|
|
||||||
|
|
||||||
def domXml(self):
|
|
||||||
return DOM_XML
|
|
||||||
|
|
||||||
def group(self):
|
|
||||||
return "BEC Input Widgets"
|
|
||||||
|
|
||||||
def icon(self):
|
|
||||||
return designer_material_icon(DeviceLineEdit.ICON_NAME)
|
|
||||||
|
|
||||||
def includeFile(self):
|
|
||||||
return "device_line_edit"
|
|
||||||
|
|
||||||
def initialize(self, form_editor):
|
|
||||||
self._form_editor = form_editor
|
|
||||||
|
|
||||||
def isContainer(self):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def isInitialized(self):
|
|
||||||
return self._form_editor is not None
|
|
||||||
|
|
||||||
def name(self):
|
|
||||||
return "DeviceLineEdit"
|
|
||||||
|
|
||||||
def toolTip(self):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def whatsThis(self):
|
|
||||||
return self.toolTip()
|
|
||||||
-17
@@ -1,17 +0,0 @@
|
|||||||
def main(): # pragma: no cover
|
|
||||||
from qtpy import PYSIDE6
|
|
||||||
|
|
||||||
if not PYSIDE6:
|
|
||||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
|
||||||
return
|
|
||||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
|
||||||
|
|
||||||
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit_plugin import (
|
|
||||||
DeviceLineEditPlugin,
|
|
||||||
)
|
|
||||||
|
|
||||||
QPyDesignerCustomWidgetCollection.addCustomWidget(DeviceLineEditPlugin())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
main()
|
|
||||||
@@ -1,37 +1,72 @@
|
|||||||
|
"""Editable combobox for selecting BEC device signals."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from qtpy.QtCore import QSize, Qt, Signal
|
from bec_lib.callback_handler import EventType
|
||||||
from qtpy.QtWidgets import QComboBox, QSizePolicy
|
from bec_lib.device import Signal as BECSignal
|
||||||
|
from bec_lib.logger import bec_logger
|
||||||
|
from pydantic import Field
|
||||||
|
from qtpy.QtCore import Property, QSize, QStringListModel, Qt, Signal, Slot
|
||||||
|
from qtpy.QtWidgets import QComboBox, QCompleter, QSizePolicy
|
||||||
|
|
||||||
|
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||||
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
from bec_widgets.utils.filter_io import ComboBoxFilterHandler, FilterIO
|
from bec_widgets.utils.filter_io import (
|
||||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
get_bec_signals_for_classes,
|
||||||
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
|
replace_combobox_items,
|
||||||
DeviceSignalInputBase,
|
signal_items_for_kind,
|
||||||
DeviceSignalInputBaseConfig,
|
|
||||||
)
|
)
|
||||||
|
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||||
|
|
||||||
|
logger = bec_logger.logger
|
||||||
|
|
||||||
|
|
||||||
class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
class SignalComboBoxConfig(ConnectionConfig):
|
||||||
|
"""Serializable configuration for :class:`SignalComboBox`.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
signal_filter: Enabled signal kind filters as ``Kind.name`` strings.
|
||||||
|
signal_class_filter: Signal class names used to build the signal list from BEC.
|
||||||
|
ndim_filter: Optional dimensionality filter for class-based signal lists.
|
||||||
|
default: Signal selected by default.
|
||||||
|
arg_name: Optional argument name used by scan/input widgets.
|
||||||
|
device: Device name used to scope kind-based signal filtering.
|
||||||
|
signals: Signal names available after filtering.
|
||||||
|
autocomplete: Whether to use the explicit completer model instead of Qt's default
|
||||||
|
editable-combobox completer.
|
||||||
"""
|
"""
|
||||||
Line edit widget for device input with autocomplete for device names.
|
|
||||||
|
signal_filter: list[str] = Field(default_factory=list)
|
||||||
|
signal_class_filter: list[str] = Field(default_factory=list)
|
||||||
|
ndim_filter: int | list[int] | None = None
|
||||||
|
default: str | None = None
|
||||||
|
arg_name: str | None = None
|
||||||
|
device: str | None = None
|
||||||
|
signals: list[str] = Field(default_factory=list)
|
||||||
|
autocomplete: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SignalComboBox(BECWidget, QComboBox):
|
||||||
|
"""Editable combobox for selecting a signal from a BEC device.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
parent: Parent widget.
|
parent: Optional parent widget.
|
||||||
client: BEC client object.
|
client: Optional BEC client object.
|
||||||
config: Device input configuration.
|
config: Signal combobox configuration as a ``SignalComboBoxConfig`` instance or
|
||||||
gui_id: GUI ID.
|
dictionary.
|
||||||
device: Device name to filter signals from.
|
gui_id: Optional GUI identifier.
|
||||||
signal_filter: Signal filter, list of signal kinds from ophyd Kind enum. Check DeviceSignalInputBase for more details.
|
device: Device name used to scope kind-based signal filtering.
|
||||||
signal_class_filter: List of signal classes to filter the signals by. Only signals of these classes will be shown.
|
signal_filter: Signal kind filter or filters from ``Kind``.
|
||||||
ndim_filter: Dimensionality filter, int or list of ints to filter signals by their number of dimensions. If signal do not support ndim, it will be included in the selection anyway.
|
signal_class_filter: Signal class names used to build a class-based signal list.
|
||||||
default: Default device name.
|
ndim_filter: Dimensionality filter for class-based signal lists.
|
||||||
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
|
default: Signal selected during initialization.
|
||||||
store_signal_config: Whether to store the full signal config in the combobox item data.
|
arg_name: Optional argument name used by scan/input widgets.
|
||||||
require_device: If True, signals are only shown/validated when a device is set.
|
store_signal_config: Whether to store each signal config in the item data.
|
||||||
Signals:
|
require_device: If True, class-based signal filtering requires a valid selected device.
|
||||||
device_signal_changed: Emitted when the current text represents a valid signal selection.
|
autocomplete: If True, use the explicit line-edit style completer. If False, keep
|
||||||
signal_reset: Emitted when validation fails and the selection should be treated as cleared.
|
Qt's default editable-combobox completion behavior.
|
||||||
|
**kwargs: Additional keyword arguments passed to ``BECWidget``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
USER_ACCESS = ["set_signal", "set_device", "signals", "get_signal_name"]
|
USER_ACCESS = ["set_signal", "set_device", "signals", "get_signal_name"]
|
||||||
@@ -47,289 +82,448 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
|||||||
self,
|
self,
|
||||||
parent=None,
|
parent=None,
|
||||||
client=None,
|
client=None,
|
||||||
config: DeviceSignalInputBaseConfig | None = None,
|
config: SignalComboBoxConfig | dict | None = None,
|
||||||
gui_id: str | None = None,
|
gui_id: str | None = None,
|
||||||
device: str | None = None,
|
device: str | None = None,
|
||||||
signal_filter: list[Kind] | None = None,
|
signal_filter: list[Kind | str] | Kind | str | None = None,
|
||||||
signal_class_filter: list[str] | None = None,
|
signal_class_filter: list[str] | None = None,
|
||||||
ndim_filter: int | list[int] | None = None,
|
ndim_filter: int | list[int] | None = None,
|
||||||
default: str | None = None,
|
default: str | None = None,
|
||||||
arg_name: str | None = None,
|
arg_name: str | None = None,
|
||||||
store_signal_config: bool = True,
|
store_signal_config: bool = True,
|
||||||
require_device: bool = False,
|
require_device: bool = False,
|
||||||
|
autocomplete: bool | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
self.config = self._process_config(config)
|
||||||
|
super().__init__(parent=parent, client=client, config=self.config, gui_id=gui_id, **kwargs)
|
||||||
|
self.get_bec_shortcuts()
|
||||||
|
|
||||||
|
self._device: str | None = None
|
||||||
|
self._signal_filter: set[Kind] = set()
|
||||||
|
self._signals: list[str | tuple[str, dict]] = []
|
||||||
|
self._hinted_signals: list[tuple[str, dict]] = []
|
||||||
|
self._normal_signals: list[tuple[str, dict]] = []
|
||||||
|
self._config_signals: list[tuple[str, dict]] = []
|
||||||
|
self._set_first_element_as_empty = False
|
||||||
|
self._signal_class_filter = signal_class_filter or []
|
||||||
|
self._store_signal_config = store_signal_config
|
||||||
|
self._require_device = require_device
|
||||||
|
self._is_valid_input = False
|
||||||
|
self._completer_model = QStringListModel(self)
|
||||||
|
|
||||||
if arg_name is not None:
|
if arg_name is not None:
|
||||||
self.config.arg_name = arg_name
|
self.config.arg_name = arg_name
|
||||||
self.arg_name = arg_name
|
self.arg_name = arg_name
|
||||||
if default is not None:
|
|
||||||
self.set_device(default)
|
|
||||||
|
|
||||||
|
if signal_filter is None and self.config.signal_filter:
|
||||||
|
signal_filter = self.config.signal_filter
|
||||||
|
if signal_class_filter is None and self.config.signal_class_filter:
|
||||||
|
self._signal_class_filter = self.config.signal_class_filter
|
||||||
|
if ndim_filter is None and self.config.ndim_filter is not None:
|
||||||
|
ndim_filter = self.config.ndim_filter
|
||||||
|
if device is None and self.config.device:
|
||||||
|
device = self.config.device
|
||||||
|
if default is None and self.config.default:
|
||||||
|
default = self.config.default
|
||||||
|
if autocomplete is not None:
|
||||||
|
self.config.autocomplete = autocomplete
|
||||||
|
self.config.ndim_filter = ndim_filter
|
||||||
|
|
||||||
|
self.setEditable(True)
|
||||||
|
self.setInsertPolicy(QComboBox.NoInsert)
|
||||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||||
self.setMinimumSize(QSize(100, 0))
|
self.setMinimumSize(QSize(100, 0))
|
||||||
self._set_first_element_as_empty = True
|
if self.config.autocomplete:
|
||||||
self._signal_class_filter = signal_class_filter or []
|
self.autocomplete = True
|
||||||
self._store_signal_config = store_signal_config
|
|
||||||
self.config.ndim_filter = ndim_filter or None
|
|
||||||
self._require_device = require_device
|
|
||||||
self._is_valid_input = False
|
|
||||||
|
|
||||||
# Note: Runtime arguments (e.g. device, default, arg_name) intentionally take
|
self._device_update_register = self.bec_dispatcher.client.callbacks.register(
|
||||||
# precedence over values from the passed-in config. Full reconciliation and
|
EventType.DEVICE_UPDATE, self.update_signals_from_filters
|
||||||
# restoration of state between designer-provided config and runtime arguments
|
)
|
||||||
# is not yet implemented, as earlier attempts caused issues with QtDesigner.
|
|
||||||
self.currentTextChanged.connect(self.on_text_changed)
|
self.currentTextChanged.connect(self.on_text_changed)
|
||||||
|
|
||||||
# Kind filtering is always applied; class filtering is additive. If signal_filter is None,
|
self.set_filter(signal_filter or [Kind.hinted, Kind.normal, Kind.config])
|
||||||
# we default to hinted+normal, even when signal_class_filter is empty or None. To disable
|
|
||||||
# kinds, pass an explicit signal_filter or toggle include_* after init.
|
|
||||||
if signal_filter is not None:
|
|
||||||
self.set_filter(signal_filter)
|
|
||||||
else:
|
|
||||||
self.set_filter([Kind.hinted, Kind.normal, Kind.config])
|
|
||||||
|
|
||||||
if device is not None:
|
if device is not None:
|
||||||
self.set_device(device)
|
self.set_device(device)
|
||||||
if default is not None:
|
if default is not None:
|
||||||
self.set_signal(default)
|
self.set_signal(default)
|
||||||
|
self.check_validity(self.currentText())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _process_config(config: SignalComboBoxConfig | dict | None) -> SignalComboBoxConfig:
|
||||||
|
"""Normalize user-provided configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Existing configuration, configuration dictionary, or None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A validated ``SignalComboBoxConfig`` instance.
|
||||||
|
"""
|
||||||
|
if config is None:
|
||||||
|
return SignalComboBoxConfig(widget_class="SignalComboBox")
|
||||||
|
return SignalComboBoxConfig.model_validate(config)
|
||||||
|
|
||||||
|
@SafeSlot(str)
|
||||||
|
def set_signal(self, signal: str):
|
||||||
|
"""Set the current signal if it is available in the combobox.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
signal: Signal display text, object name, or component name to select.
|
||||||
|
"""
|
||||||
|
display_text = self._display_text_for_signal(signal)
|
||||||
|
if display_text is None:
|
||||||
|
logger.warning(
|
||||||
|
f"Signal {signal} not found for device {self.device} and filtered selection {self.signal_filter}."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.setCurrentText(display_text)
|
||||||
|
self.config.default = signal
|
||||||
|
|
||||||
@SafeSlot(str)
|
@SafeSlot(str)
|
||||||
def set_device(self, device: str | None):
|
def set_device(self, device: str | None):
|
||||||
"""
|
"""Set the device that scopes kind-based signal filtering.
|
||||||
Set the device. When signal_class_filter is active, ensures base-class
|
|
||||||
logic runs and then refreshes the signal list to show only signals from
|
|
||||||
that device matching the signal class filter.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
device(str): device name.
|
device: Device name to use for signal filtering. Invalid or empty values clear
|
||||||
|
the current device and signal selection.
|
||||||
"""
|
"""
|
||||||
super().set_device(device)
|
previous_device = self._device
|
||||||
|
valid_device = device if self.validate_device(device) else None
|
||||||
if self._signal_class_filter:
|
self._device = valid_device
|
||||||
# Refresh the signal list to show only this device's signals
|
self.config.device = self._device
|
||||||
self.update_signals_from_signal_classes()
|
if valid_device is None or valid_device != previous_device:
|
||||||
|
self.setCurrentText("")
|
||||||
|
self.update_signals_from_filters()
|
||||||
|
|
||||||
@SafeSlot()
|
@SafeSlot()
|
||||||
@SafeSlot(dict, dict)
|
@SafeSlot(dict, dict)
|
||||||
def update_signals_from_filters(
|
def update_signals_from_filters(
|
||||||
self, content: dict | None = None, metadata: dict | None = None
|
self, content: dict | None = None, metadata: dict | None = None
|
||||||
):
|
):
|
||||||
"""Update the filters for the combobox.
|
"""Refresh available signals from the current device and filters.
|
||||||
When signal_class_filter is active, skip the normal Kind-based filtering.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
content (dict | None): Content dictionary from BEC event.
|
content: Optional callback payload from BEC device updates. Currently unused.
|
||||||
metadata (dict | None): Metadata dictionary from BEC event.
|
metadata: Optional callback metadata from BEC device updates. Currently unused.
|
||||||
"""
|
"""
|
||||||
super().update_signals_from_filters(content, metadata)
|
self.config.signal_filter = [kind.name for kind in self.signal_filter]
|
||||||
|
|
||||||
if self._signal_class_filter:
|
if self._signal_class_filter:
|
||||||
self.update_signals_from_signal_classes()
|
self.update_signals_from_signal_classes()
|
||||||
return
|
return
|
||||||
# pylint: disable=protected-access
|
|
||||||
if FilterIO._find_handler(self) is ComboBoxFilterHandler:
|
if not self.validate_device(self._device):
|
||||||
if len(self._config_signals) > 0:
|
self._device = None
|
||||||
self.insertItem(
|
self.config.device = None
|
||||||
len(self._hinted_signals) + len(self._normal_signals), "Config Signals"
|
self._set_signal_groups([], [], [])
|
||||||
)
|
return
|
||||||
self.model().item(len(self._hinted_signals) + len(self._normal_signals)).setEnabled(
|
|
||||||
False
|
device = self.get_device_object(self._device)
|
||||||
)
|
device_info = device._info.get("signals", {})
|
||||||
if len(self._normal_signals) > 0:
|
|
||||||
self.insertItem(len(self._hinted_signals), "Normal Signals")
|
if isinstance(device, BECSignal):
|
||||||
self.model().item(len(self._hinted_signals)).setEnabled(False)
|
self._set_signal_groups([(self._device, {})], [], [])
|
||||||
if len(self._hinted_signals) > 0:
|
return
|
||||||
self.insertItem(0, "Hinted Signals")
|
|
||||||
self.model().item(0).setEnabled(False)
|
self._set_signal_groups(
|
||||||
|
signal_items_for_kind(
|
||||||
|
kind=Kind.hinted,
|
||||||
|
signal_filter=self.signal_filter,
|
||||||
|
device_info=device_info,
|
||||||
|
device_name=self._device,
|
||||||
|
),
|
||||||
|
signal_items_for_kind(
|
||||||
|
kind=Kind.normal,
|
||||||
|
signal_filter=self.signal_filter,
|
||||||
|
device_info=device_info,
|
||||||
|
device_name=self._device,
|
||||||
|
),
|
||||||
|
signal_items_for_kind(
|
||||||
|
kind=Kind.config,
|
||||||
|
signal_filter=self.signal_filter,
|
||||||
|
device_info=device_info,
|
||||||
|
device_name=self._device,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Property(str)
|
||||||
|
def device(self) -> str:
|
||||||
|
"""Selected device."""
|
||||||
|
return self._device or ""
|
||||||
|
|
||||||
|
@device.setter
|
||||||
|
def device(self, value: str):
|
||||||
|
self.set_device(value)
|
||||||
|
|
||||||
|
@Property(bool)
|
||||||
|
def include_hinted_signals(self):
|
||||||
|
"""Include hinted signals."""
|
||||||
|
return Kind.hinted in self.signal_filter
|
||||||
|
|
||||||
|
@include_hinted_signals.setter
|
||||||
|
def include_hinted_signals(self, value: bool):
|
||||||
|
self._set_kind_filter_enabled(Kind.hinted, value)
|
||||||
|
|
||||||
|
@Property(bool)
|
||||||
|
def include_normal_signals(self):
|
||||||
|
"""Include normal signals."""
|
||||||
|
return Kind.normal in self.signal_filter
|
||||||
|
|
||||||
|
@include_normal_signals.setter
|
||||||
|
def include_normal_signals(self, value: bool):
|
||||||
|
self._set_kind_filter_enabled(Kind.normal, value)
|
||||||
|
|
||||||
|
@Property(bool)
|
||||||
|
def include_config_signals(self):
|
||||||
|
"""Include config signals."""
|
||||||
|
return Kind.config in self.signal_filter
|
||||||
|
|
||||||
|
@include_config_signals.setter
|
||||||
|
def include_config_signals(self, value: bool):
|
||||||
|
self._set_kind_filter_enabled(Kind.config, value)
|
||||||
|
|
||||||
@SafeProperty(bool)
|
@SafeProperty(bool)
|
||||||
def set_first_element_as_empty(self) -> bool:
|
def set_first_element_as_empty(self) -> bool:
|
||||||
"""
|
"""Whether an empty choice is inserted as the first item."""
|
||||||
Whether the first element in the combobox should be empty.
|
|
||||||
This is useful to allow the user to select a device from the list.
|
|
||||||
"""
|
|
||||||
return self._set_first_element_as_empty
|
return self._set_first_element_as_empty
|
||||||
|
|
||||||
@set_first_element_as_empty.setter
|
@set_first_element_as_empty.setter
|
||||||
def set_first_element_as_empty(self, value: bool) -> None:
|
def set_first_element_as_empty(self, value: bool) -> None:
|
||||||
"""
|
|
||||||
Set whether the first element in the combobox should be empty.
|
|
||||||
This is useful to allow the user to select a device from the list.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
value (bool): True if the first element should be empty, False otherwise.
|
|
||||||
"""
|
|
||||||
self._set_first_element_as_empty = value
|
self._set_first_element_as_empty = value
|
||||||
if self._set_first_element_as_empty:
|
if value:
|
||||||
self.insertItem(0, "")
|
if self.count() == 0 or self.itemText(0) != "":
|
||||||
|
self.insertItem(0, "")
|
||||||
self.setCurrentIndex(0)
|
self.setCurrentIndex(0)
|
||||||
else:
|
elif self.count() > 0 and self.itemText(0) == "":
|
||||||
if self.count() > 0 and self.itemText(0) == "":
|
self.removeItem(0)
|
||||||
self.removeItem(0)
|
|
||||||
|
|
||||||
@SafeProperty("QStringList")
|
@SafeProperty("QStringList")
|
||||||
def signal_class_filter(self) -> list[str]:
|
def signal_class_filter(self) -> list[str]:
|
||||||
"""
|
"""Signal class names used to build the signal list."""
|
||||||
Get the list of signal classes to filter.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[str]: List of signal class names to filter.
|
|
||||||
"""
|
|
||||||
return self._signal_class_filter
|
return self._signal_class_filter
|
||||||
|
|
||||||
@signal_class_filter.setter
|
@signal_class_filter.setter
|
||||||
def signal_class_filter(self, value: list[str] | None):
|
def signal_class_filter(self, value: list[str] | None):
|
||||||
"""
|
self._signal_class_filter = value or []
|
||||||
Set the signal class filter.
|
self.config.signal_class_filter = self._signal_class_filter
|
||||||
|
self.update_signals_from_filters()
|
||||||
Args:
|
|
||||||
value (list[str] | None): List of signal class names to filter, or None/empty
|
|
||||||
to disable class-based filtering and revert to the default behavior.
|
|
||||||
"""
|
|
||||||
normalized_value = value or []
|
|
||||||
self._signal_class_filter = normalized_value
|
|
||||||
self.config.signal_class_filter = normalized_value
|
|
||||||
if self._signal_class_filter:
|
|
||||||
self.update_signals_from_signal_classes()
|
|
||||||
else:
|
|
||||||
self.update_signals_from_filters()
|
|
||||||
|
|
||||||
@SafeProperty(int)
|
@SafeProperty(int)
|
||||||
def ndim_filter(self) -> int:
|
def ndim_filter(self) -> int:
|
||||||
"""Dimensionality filter for signals."""
|
"""Dimensionality filter for signal-class based lists."""
|
||||||
return self.config.ndim_filter if isinstance(self.config.ndim_filter, int) else -1
|
return self.config.ndim_filter if isinstance(self.config.ndim_filter, int) else -1
|
||||||
|
|
||||||
@ndim_filter.setter
|
@ndim_filter.setter
|
||||||
def ndim_filter(self, value: int):
|
def ndim_filter(self, value: int):
|
||||||
self.config.ndim_filter = None if value < 0 else value
|
self.config.ndim_filter = None if value < 0 else value
|
||||||
if self._signal_class_filter:
|
self.update_signals_from_filters()
|
||||||
self.update_signals_from_signal_classes(ndim_filter=self.config.ndim_filter)
|
|
||||||
|
|
||||||
@SafeProperty(bool)
|
@SafeProperty(bool)
|
||||||
def require_device(self) -> bool:
|
def require_device(self) -> bool:
|
||||||
"""
|
"""Whether validation/listing requires a selected device."""
|
||||||
If True, signals are only shown/validated when a device is set.
|
|
||||||
|
|
||||||
Note:
|
|
||||||
This property affects list rebuilding only when a signal_class_filter
|
|
||||||
is active. Without a signal class filter, the available signals are
|
|
||||||
managed by the standard Kind-based filtering.
|
|
||||||
"""
|
|
||||||
return self._require_device
|
return self._require_device
|
||||||
|
|
||||||
@require_device.setter
|
@require_device.setter
|
||||||
def require_device(self, value: bool):
|
def require_device(self, value: bool):
|
||||||
self._require_device = value
|
self._require_device = value
|
||||||
# Rebuild list when toggled, but only when using signal_class_filter
|
self.update_signals_from_filters()
|
||||||
if self._signal_class_filter:
|
|
||||||
self.update_signals_from_signal_classes()
|
|
||||||
|
|
||||||
def set_to_obj_name(self, obj_name: str) -> bool:
|
@SafeProperty(bool)
|
||||||
"""
|
def autocomplete(self) -> bool:
|
||||||
Set the combobox to the object name of the signal.
|
"""Whether autocomplete suggestions are enabled while editing."""
|
||||||
|
return self.config.autocomplete
|
||||||
|
|
||||||
|
@autocomplete.setter
|
||||||
|
def autocomplete(self, value: bool) -> None:
|
||||||
|
self.config.autocomplete = value
|
||||||
|
if value:
|
||||||
|
self.setCompleter(QCompleter(self._completer_model, self))
|
||||||
|
else:
|
||||||
|
self.setCompleter(QCompleter(self.model(), self))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def signals(self) -> list[str | tuple[str, dict]]:
|
||||||
|
"""Available signals after filtering."""
|
||||||
|
return self._signals
|
||||||
|
|
||||||
|
@signals.setter
|
||||||
|
def signals(self, value: list[str | tuple[str, dict]]):
|
||||||
|
self._signals = value
|
||||||
|
self.config.signals = [entry[0] if isinstance(entry, tuple) else entry for entry in value]
|
||||||
|
self._replace_signal_items()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def signal_filter(self) -> set[Kind]:
|
||||||
|
"""Signal kind filters."""
|
||||||
|
return self._signal_filter
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_valid_input(self) -> bool:
|
||||||
|
"""Whether the current text represents a valid signal selection."""
|
||||||
|
return self._is_valid_input
|
||||||
|
|
||||||
|
def setEnabled(self, enabled: bool) -> None: # noqa: N802
|
||||||
|
super().setEnabled(enabled)
|
||||||
|
self._update_validity_style(self._is_valid_input)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def selected_signal_comp_name(self) -> str:
|
||||||
|
"""Component name for the current signal, falling back to object name."""
|
||||||
|
index = self._find_signal_index(self.currentText())
|
||||||
|
if index < 0:
|
||||||
|
return self.get_signal_name()
|
||||||
|
signal_info = self.itemData(index)
|
||||||
|
if isinstance(signal_info, dict):
|
||||||
|
return signal_info.get("component_name") or self.get_signal_name()
|
||||||
|
return self.get_signal_name()
|
||||||
|
|
||||||
|
def set_filter(self, filter_selection: Kind | str | list[Kind | str] | None):
|
||||||
|
"""Enable one or more signal kind filters.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
obj_name (str): Object name of the signal.
|
filter_selection: Filter or filters to enable. Strings must match ``Kind`` member
|
||||||
|
names.
|
||||||
|
"""
|
||||||
|
if filter_selection is None:
|
||||||
|
return
|
||||||
|
filters = filter_selection if isinstance(filter_selection, list) else [filter_selection]
|
||||||
|
for signal_filter in filters:
|
||||||
|
kind = self._normalize_kind(signal_filter)
|
||||||
|
if kind is not None:
|
||||||
|
self._signal_filter.add(kind)
|
||||||
|
self.update_signals_from_filters()
|
||||||
|
|
||||||
|
def get_device_object(self, device: str) -> object | None:
|
||||||
|
"""Return a BEC device object by name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device: Device name.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if the object name was found and set, False otherwise.
|
Device object if it exists in the device manager, otherwise None.
|
||||||
"""
|
"""
|
||||||
for i in range(self.count()):
|
dev = getattr(self.dev, device, None)
|
||||||
signal_data = self.itemData(i)
|
if dev is None:
|
||||||
if signal_data and signal_data.get("obj_name") == obj_name:
|
logger.warning(f"Device {device} not found in devicemanager.")
|
||||||
self.setCurrentIndex(i)
|
return None
|
||||||
return True
|
return dev
|
||||||
|
|
||||||
|
def validate_device(self, device: str | None, raise_on_false: bool = False) -> bool:
|
||||||
|
"""Validate that a device exists in the current device manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device: Device name to validate.
|
||||||
|
raise_on_false: If True, raise instead of returning False for missing devices.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the device exists in the current device manager.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If ``raise_on_false`` is True and the device is missing.
|
||||||
|
"""
|
||||||
|
if device in self.dev:
|
||||||
|
return True
|
||||||
|
if raise_on_false:
|
||||||
|
raise ValueError(f"Device {device} not found in devicemanager.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def set_to_first_enabled(self) -> bool:
|
def validate_signal(self, signal: str) -> bool:
|
||||||
"""
|
"""Validate a signal by display text, object name, or component name.
|
||||||
Set the combobox to the first enabled item.
|
|
||||||
|
Args:
|
||||||
|
signal: Signal display text, object name, or component name.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if an enabled item was found and set, False otherwise.
|
True if the signal is present in the current filtered signal list.
|
||||||
"""
|
"""
|
||||||
for i in range(self.count()):
|
if not signal:
|
||||||
if self.model().item(i).isEnabled():
|
return False
|
||||||
self.setCurrentIndex(i)
|
return self._display_text_for_signal(signal) is not None
|
||||||
|
|
||||||
|
def set_to_obj_name(self, obj_name: str) -> bool:
|
||||||
|
"""Select the item whose signal config has the given object name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
obj_name: Signal object name to select.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if a matching signal was selected.
|
||||||
|
"""
|
||||||
|
index = self._find_signal_index(obj_name)
|
||||||
|
if index < 0:
|
||||||
|
return False
|
||||||
|
self.setCurrentIndex(index)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def set_to_first_enabled(self) -> bool:
|
||||||
|
"""Select the first enabled item.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if an enabled item was found and selected.
|
||||||
|
"""
|
||||||
|
for index in range(self.count()):
|
||||||
|
item = self.model().item(index)
|
||||||
|
if item is not None and item.isEnabled():
|
||||||
|
self.setCurrentIndex(index)
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_signal_name(self) -> str:
|
def get_signal_name(self) -> str:
|
||||||
"""
|
"""Return the selected signal object name when available.
|
||||||
Get the signal name from the combobox.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: The signal name.
|
Signal object name from item data, or the current display text when no item data
|
||||||
|
is available.
|
||||||
"""
|
"""
|
||||||
signal_name = self.currentText()
|
current_text = self.currentText()
|
||||||
index = self.findText(signal_name)
|
index = self._find_signal_index(current_text)
|
||||||
if index == -1:
|
if index < 0:
|
||||||
return signal_name
|
return current_text
|
||||||
|
|
||||||
signal_info = self.itemData(index)
|
signal_info = self.itemData(index)
|
||||||
if signal_info:
|
if isinstance(signal_info, dict):
|
||||||
signal_name = signal_info.get("obj_name", signal_name)
|
return signal_info.get("obj_name") or current_text
|
||||||
|
return current_text
|
||||||
return signal_name if signal_name else ""
|
|
||||||
|
|
||||||
def get_signal_config(self) -> dict | None:
|
def get_signal_config(self) -> dict | None:
|
||||||
"""
|
"""Return the selected signal config if item-data storage is enabled.
|
||||||
Get the signal config from the combobox for the currently selected signal.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict | None: The signal configuration dictionary or None if not available.
|
Selected signal configuration dictionary, or None when item-data storage is disabled
|
||||||
|
or the current item has no configuration.
|
||||||
"""
|
"""
|
||||||
if not self._store_signal_config:
|
if not self._store_signal_config:
|
||||||
return None
|
return None
|
||||||
|
signal_info = self.itemData(self.currentIndex())
|
||||||
index = self.currentIndex()
|
return signal_info if isinstance(signal_info, dict) else None
|
||||||
if index == -1:
|
|
||||||
return None
|
|
||||||
|
|
||||||
signal_info = self.itemData(index)
|
|
||||||
return signal_info if signal_info else None
|
|
||||||
|
|
||||||
def update_signals_from_signal_classes(self, ndim_filter: int | list[int] | None = None):
|
def update_signals_from_signal_classes(self, ndim_filter: int | list[int] | None = None):
|
||||||
"""
|
"""Refresh signals from ``device_manager.get_bec_signals`` for class-based filtering.
|
||||||
Update the combobox with signals filtered by signal classes and optionally by ndim.
|
|
||||||
Uses device_manager.get_bec_signals() to retrieve signals.
|
|
||||||
If a device is set, only shows signals from that device.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ndim_filter (int | list[int] | None): Filter signals by dimensionality.
|
ndim_filter: Optional dimensionality filter overriding the configured value for
|
||||||
If provided, only signals with matching ndim will be included.
|
this refresh.
|
||||||
Can be a single int or a list of ints. Use None to include all dimensions.
|
|
||||||
If not provided, uses the previously set ndim_filter.
|
|
||||||
"""
|
"""
|
||||||
if not self._signal_class_filter:
|
if not self._signal_class_filter:
|
||||||
return
|
return
|
||||||
|
|
||||||
if self._require_device and not self._device:
|
if self._require_device and not self._device:
|
||||||
self.clear()
|
self.signals = []
|
||||||
self._signals = []
|
|
||||||
FilterIO.set_selection(widget=self, selection=self._signals)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Update stored ndim_filter if a new one is provided
|
|
||||||
if ndim_filter is not None:
|
if ndim_filter is not None:
|
||||||
self.config.ndim_filter = ndim_filter
|
self.config.ndim_filter = ndim_filter
|
||||||
|
|
||||||
self.clear()
|
signals = get_bec_signals_for_classes(
|
||||||
|
|
||||||
# Get signals with ndim filtering applied at the FilterIO level
|
|
||||||
signals = FilterIO.update_with_signal_class(
|
|
||||||
widget=self,
|
|
||||||
signal_class_filter=self._signal_class_filter,
|
|
||||||
client=self.client,
|
client=self.client,
|
||||||
ndim_filter=self.config.ndim_filter, # Pass ndim_filter to FilterIO
|
signal_class_filter=self._signal_class_filter,
|
||||||
|
ndim_filter=self.config.ndim_filter,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Track signals for validation and FilterIO selection
|
combo_items: list[str | tuple[str, dict]] = []
|
||||||
self._signals = []
|
item_tooltips: dict[int, str] = {}
|
||||||
|
|
||||||
for device_name, signal_name, signal_config in signals:
|
for device_name, signal_name, signal_config in signals:
|
||||||
# Filter by device if one is set
|
|
||||||
if self._device and device_name != self._device:
|
if self._device and device_name != self._device:
|
||||||
continue
|
continue
|
||||||
if self._signal_filter:
|
if self._signal_filter:
|
||||||
@@ -339,76 +533,154 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
|||||||
}:
|
}:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Get storage_name for tooltip
|
|
||||||
storage_name = signal_config.get("storage_name", "")
|
|
||||||
|
|
||||||
# Store the full signal config as item data if requested
|
|
||||||
if self._store_signal_config:
|
if self._store_signal_config:
|
||||||
self.addItem(signal_name, signal_config)
|
combo_items.append((signal_name, signal_config))
|
||||||
else:
|
else:
|
||||||
self.addItem(signal_name)
|
combo_items.append(signal_name)
|
||||||
|
|
||||||
# Track for validation
|
storage_name = signal_config.get("storage_name", "")
|
||||||
self._signals.append(signal_name)
|
|
||||||
|
|
||||||
# Set tooltip to storage_name (Qt.ToolTipRole = 3)
|
|
||||||
if storage_name:
|
if storage_name:
|
||||||
self.setItemData(self.count() - 1, storage_name, Qt.ItemDataRole.ToolTipRole)
|
item_tooltips[len(combo_items) - 1] = storage_name
|
||||||
|
|
||||||
# Keep FilterIO selection in sync for validate_signal
|
self.signals = combo_items
|
||||||
FilterIO.set_selection(widget=self, selection=self._signals)
|
tooltip_offset = 1 if self._set_first_element_as_empty and self.count() else 0
|
||||||
|
for item_index, tooltip in item_tooltips.items():
|
||||||
|
self.setItemData(item_index + tooltip_offset, tooltip, Qt.ItemDataRole.ToolTipRole)
|
||||||
|
self.check_validity(self.currentText())
|
||||||
|
|
||||||
@SafeSlot()
|
@SafeSlot()
|
||||||
def reset_selection(self):
|
def reset_selection(self):
|
||||||
"""Reset the selection of the combobox."""
|
"""Reset the current selection and refresh available signals."""
|
||||||
self.clear()
|
self.setCurrentText("")
|
||||||
self.setItemText(0, "Select a device")
|
|
||||||
self.update_signals_from_filters()
|
self.update_signals_from_filters()
|
||||||
self.device_signal_changed.emit("")
|
self.device_signal_changed.emit("")
|
||||||
|
|
||||||
@SafeSlot(str)
|
@SafeSlot(str)
|
||||||
def on_text_changed(self, text: str):
|
def on_text_changed(self, text: str):
|
||||||
"""Validate and emit only when the signal is valid.
|
"""Validate the current text when edited or selected.
|
||||||
For a positioner, the readback value has to be renamed to the device name.
|
|
||||||
When using signal_class_filter, device validation is skipped.
|
Args:
|
||||||
|
text: Current combobox text.
|
||||||
"""
|
"""
|
||||||
self.check_validity(text)
|
self.check_validity(text)
|
||||||
|
|
||||||
|
@Slot(str)
|
||||||
def check_validity(self, input_text: str) -> None:
|
def check_validity(self, input_text: str) -> None:
|
||||||
"""Check if the current value is a valid signal and emit only when valid."""
|
"""Validate current text and update visual state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_text: Current combobox text.
|
||||||
|
"""
|
||||||
if self._signal_class_filter:
|
if self._signal_class_filter:
|
||||||
if self._require_device and (not self._device or not input_text):
|
is_valid = not (self._require_device and not self._device) and self.validate_signal(
|
||||||
is_valid = False
|
input_text
|
||||||
else:
|
)
|
||||||
is_valid = self.validate_signal(input_text)
|
|
||||||
else:
|
else:
|
||||||
if self._require_device and not self.validate_device(self._device):
|
is_valid = self.validate_device(self._device) and self.validate_signal(input_text)
|
||||||
is_valid = False
|
|
||||||
else:
|
|
||||||
is_valid = self.validate_device(self._device) and self.validate_signal(input_text)
|
|
||||||
|
|
||||||
if is_valid:
|
if is_valid:
|
||||||
self._is_valid_input = True
|
self._is_valid_input = True
|
||||||
self.device_signal_changed.emit(input_text)
|
self.device_signal_changed.emit(input_text)
|
||||||
self.setStyleSheet("border: 1px solid transparent;")
|
|
||||||
else:
|
else:
|
||||||
self._is_valid_input = False
|
self._is_valid_input = False
|
||||||
self.signal_reset.emit()
|
self.signal_reset.emit()
|
||||||
if self.isEnabled():
|
self._update_validity_style(self._is_valid_input)
|
||||||
self.setStyleSheet("border: 1px solid red;")
|
|
||||||
|
|
||||||
@property
|
def cleanup(self):
|
||||||
def selected_signal_comp_name(self) -> str:
|
"""Cleanup the widget."""
|
||||||
return dict(self.signals).get(self.currentText(), {}).get("component_name", "")
|
self.bec_dispatcher.client.callbacks.remove(self._device_update_register)
|
||||||
|
super().cleanup()
|
||||||
|
|
||||||
@property
|
@staticmethod
|
||||||
def is_valid_input(self) -> bool:
|
def _normalize_kind(value: Kind | str) -> Kind | None:
|
||||||
"""Whether the current text represents a valid signal selection."""
|
if isinstance(value, Kind):
|
||||||
return self._is_valid_input
|
return value
|
||||||
|
return Kind.__members__.get(value) or Kind.__members__.get(value.lower())
|
||||||
|
|
||||||
|
def _set_kind_filter_enabled(self, kind: Kind, enabled: bool):
|
||||||
|
if enabled:
|
||||||
|
self._signal_filter.add(kind)
|
||||||
|
else:
|
||||||
|
self._signal_filter.discard(kind)
|
||||||
|
self.update_signals_from_filters()
|
||||||
|
|
||||||
|
def _set_signal_groups(
|
||||||
|
self,
|
||||||
|
hinted: list[tuple[str, dict]],
|
||||||
|
normal: list[tuple[str, dict]],
|
||||||
|
config: list[tuple[str, dict]],
|
||||||
|
) -> None:
|
||||||
|
self._hinted_signals = hinted
|
||||||
|
self._normal_signals = normal
|
||||||
|
self._config_signals = config
|
||||||
|
self.signals = self._hinted_signals + self._normal_signals + self._config_signals
|
||||||
|
self._insert_group_headers()
|
||||||
|
self.check_validity(self.currentText())
|
||||||
|
|
||||||
|
def _update_validity_style(self, is_valid: bool) -> None:
|
||||||
|
border_color = "transparent" if is_valid or not self.isEnabled() else "red"
|
||||||
|
self.setStyleSheet(f"border: 1px solid {border_color};")
|
||||||
|
|
||||||
|
def _replace_signal_items(self, items: list[str | tuple[str, dict]] | None = None):
|
||||||
|
combo_items = self._signals if items is None else items
|
||||||
|
display_items = [""] + combo_items if self._set_first_element_as_empty else combo_items
|
||||||
|
replace_combobox_items(
|
||||||
|
self, display_items, preserve_current_text=bool(self.currentText()), block_signals=True
|
||||||
|
)
|
||||||
|
self._completer_model.setStringList(
|
||||||
|
[entry if isinstance(entry, str) else entry[0] for entry in combo_items]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _insert_group_headers(self):
|
||||||
|
offset = (
|
||||||
|
1
|
||||||
|
if self._set_first_element_as_empty and self.count() > 0 and self.itemText(0) == ""
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
if self._config_signals:
|
||||||
|
index = offset + len(self._hinted_signals) + len(self._normal_signals)
|
||||||
|
self.insertItem(index, "Config Signals")
|
||||||
|
self.model().item(index).setEnabled(False)
|
||||||
|
if self._normal_signals:
|
||||||
|
index = offset + len(self._hinted_signals)
|
||||||
|
self.insertItem(index, "Normal Signals")
|
||||||
|
self.model().item(index).setEnabled(False)
|
||||||
|
if self._hinted_signals:
|
||||||
|
index = offset
|
||||||
|
self.insertItem(index, "Hinted Signals")
|
||||||
|
self.model().item(index).setEnabled(False)
|
||||||
|
|
||||||
|
def _display_text_for_signal(self, signal: str) -> str | None:
|
||||||
|
for entry in self._signals:
|
||||||
|
display_text = entry[0] if isinstance(entry, tuple) else entry
|
||||||
|
if display_text == signal:
|
||||||
|
return display_text
|
||||||
|
if isinstance(entry, tuple) and self._signal_info_matches(entry[1], signal):
|
||||||
|
return display_text
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _signal_info_matches(signal_info: dict, signal: str) -> bool:
|
||||||
|
if not signal:
|
||||||
|
return False
|
||||||
|
return signal in {
|
||||||
|
signal_info.get("obj_name"),
|
||||||
|
signal_info.get("component_name"),
|
||||||
|
signal_info.get("component_name", "").replace(".", "_"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _find_signal_index(self, signal: str) -> int:
|
||||||
|
index = self.findText(signal)
|
||||||
|
if index >= 0:
|
||||||
|
return index
|
||||||
|
for item_index in range(self.count()):
|
||||||
|
signal_info = self.itemData(item_index)
|
||||||
|
if isinstance(signal_info, dict) and self._signal_info_matches(signal_info, signal):
|
||||||
|
return item_index
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
# pylint: disable=import-outside-toplevel
|
|
||||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils.colors import apply_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
@@ -417,16 +689,14 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
apply_theme("dark")
|
apply_theme("dark")
|
||||||
widget = QWidget()
|
widget = QWidget()
|
||||||
widget.setFixedSize(200, 200)
|
widget.setFixedSize(200, 200)
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout(widget)
|
||||||
widget.setLayout(layout)
|
|
||||||
box = SignalComboBox(
|
box = SignalComboBox(
|
||||||
device="waveform",
|
device="waveform",
|
||||||
signal_class_filter=["AsyncSignal", "AsyncMultiSignal"],
|
signal_class_filter=["AsyncSignal", "AsyncMultiSignal"],
|
||||||
ndim_filter=[1, 2],
|
ndim_filter=[1, 2],
|
||||||
store_signal_config=True,
|
store_signal_config=True,
|
||||||
signal_filter=[Kind.hinted, Kind.normal, Kind.config],
|
signal_filter=[Kind.hinted, Kind.normal, Kind.config],
|
||||||
) # change signal filter class to test
|
)
|
||||||
box.setEditable(True)
|
|
||||||
layout.addWidget(box)
|
layout.addWidget(box)
|
||||||
widget.show()
|
widget.show()
|
||||||
app.exec_()
|
app.exec_()
|
||||||
|
|||||||
-17
@@ -1,17 +0,0 @@
|
|||||||
def main(): # pragma: no cover
|
|
||||||
from qtpy import PYSIDE6
|
|
||||||
|
|
||||||
if not PYSIDE6:
|
|
||||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
|
||||||
return
|
|
||||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
|
||||||
|
|
||||||
from bec_widgets.widgets.control.device_input.signal_line_edit.signal_line_edit_plugin import (
|
|
||||||
SignalLineEditPlugin,
|
|
||||||
)
|
|
||||||
|
|
||||||
QPyDesignerCustomWidgetCollection.addCustomWidget(SignalLineEditPlugin())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
main()
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
from bec_lib.device import Positioner
|
|
||||||
from qtpy.QtCore import QSize, Signal, Slot
|
|
||||||
from qtpy.QtGui import QPainter, QPaintEvent, QPen
|
|
||||||
from qtpy.QtWidgets import QCompleter, QLineEdit, QSizePolicy
|
|
||||||
|
|
||||||
from bec_widgets.utils.colors import get_accent_colors
|
|
||||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
|
||||||
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
|
|
||||||
DeviceSignalInputBase,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SignalLineEdit(DeviceSignalInputBase, QLineEdit):
|
|
||||||
"""
|
|
||||||
Line edit widget for device input with autocomplete for device names.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
parent: Parent widget.
|
|
||||||
client: BEC client object.
|
|
||||||
config: Device input configuration.
|
|
||||||
gui_id: GUI ID.
|
|
||||||
device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details.
|
|
||||||
default: Default device name.
|
|
||||||
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
|
|
||||||
"""
|
|
||||||
|
|
||||||
USER_ACCESS = ["_is_valid_input", "set_signal", "set_device", "signals"]
|
|
||||||
|
|
||||||
device_signal_changed = Signal(str)
|
|
||||||
|
|
||||||
PLUGIN = True
|
|
||||||
RPC = False
|
|
||||||
ICON_NAME = "vital_signs"
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
parent=None,
|
|
||||||
client=None,
|
|
||||||
config: DeviceSignalInputBase = None,
|
|
||||||
gui_id: str | None = None,
|
|
||||||
device: str | None = None,
|
|
||||||
signal_filter: str | list[str] | None = None,
|
|
||||||
default: str | None = None,
|
|
||||||
arg_name: str | None = None,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
self.__is_valid_input = False
|
|
||||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
|
||||||
self._accent_colors = get_accent_colors()
|
|
||||||
self.completer = QCompleter(self)
|
|
||||||
self.setCompleter(self.completer)
|
|
||||||
if arg_name is not None:
|
|
||||||
self.config.arg_name = arg_name
|
|
||||||
self.arg_name = arg_name
|
|
||||||
if default is not None:
|
|
||||||
self.set_device(default)
|
|
||||||
|
|
||||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
|
||||||
self.setMinimumSize(QSize(100, 0))
|
|
||||||
# We do not consider the config that is passed here, this produced problems
|
|
||||||
# with QtDesigner, since config and input arguments may differ and resolve properly
|
|
||||||
# Implementing this logic and config recoverage is postponed.
|
|
||||||
if signal_filter is not None:
|
|
||||||
self.set_filter(signal_filter)
|
|
||||||
else:
|
|
||||||
self.set_filter([Kind.hinted, Kind.normal, Kind.config])
|
|
||||||
if device is not None:
|
|
||||||
self.set_device(device)
|
|
||||||
if default is not None:
|
|
||||||
self.set_signal(default)
|
|
||||||
self.textChanged.connect(self.check_validity)
|
|
||||||
self.check_validity(self.text())
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _is_valid_input(self) -> bool:
|
|
||||||
"""
|
|
||||||
Check if the current value is a valid device name.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if the current value is a valid device name, False otherwise.
|
|
||||||
"""
|
|
||||||
return self.__is_valid_input
|
|
||||||
|
|
||||||
@_is_valid_input.setter
|
|
||||||
def _is_valid_input(self, value: bool) -> None:
|
|
||||||
self.__is_valid_input = value
|
|
||||||
|
|
||||||
def get_current_device(self) -> object:
|
|
||||||
"""
|
|
||||||
Get the current device object based on the current value.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal.
|
|
||||||
"""
|
|
||||||
dev_name = self.text()
|
|
||||||
return self.get_device_object(dev_name)
|
|
||||||
|
|
||||||
def paintEvent(self, event: QPaintEvent) -> None:
|
|
||||||
"""Extend the paint event to set the border color based on the validity of the input.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event (PySide6.QtGui.QPaintEvent) : Paint event.
|
|
||||||
"""
|
|
||||||
super().paintEvent(event)
|
|
||||||
painter = QPainter(self)
|
|
||||||
pen = QPen()
|
|
||||||
pen.setWidth(2)
|
|
||||||
|
|
||||||
if self._is_valid_input is False and self.isEnabled() is True:
|
|
||||||
pen.setColor(self._accent_colors.emergency)
|
|
||||||
painter.setPen(pen)
|
|
||||||
painter.drawRect(self.rect().adjusted(1, 1, -1, -1))
|
|
||||||
|
|
||||||
@Slot(str)
|
|
||||||
def check_validity(self, input_text: str) -> None:
|
|
||||||
"""
|
|
||||||
Check if the current value is a valid device name.
|
|
||||||
"""
|
|
||||||
if self.validate_signal(input_text) is True:
|
|
||||||
self._is_valid_input = True
|
|
||||||
self.on_text_changed(input_text)
|
|
||||||
else:
|
|
||||||
self._is_valid_input = False
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
@Slot(str)
|
|
||||||
def on_text_changed(self, text: str):
|
|
||||||
"""Slot for text changed. If a device is selected and the signal is changed and valid it emits a signal.
|
|
||||||
For a positioner, the readback value has to be renamed to the device name.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text (str): Text in the combobox.
|
|
||||||
"""
|
|
||||||
print("test")
|
|
||||||
if self.validate_device(self.device) is False:
|
|
||||||
return
|
|
||||||
if self.validate_signal(text) is False:
|
|
||||||
return
|
|
||||||
if text == "readback" and isinstance(self.get_device_object(self.device), Positioner):
|
|
||||||
device_signal = self.device
|
|
||||||
else:
|
|
||||||
device_signal = f"{self.device}_{text}"
|
|
||||||
self.device_signal_changed.emit(device_signal)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
# pylint: disable=import-outside-toplevel
|
|
||||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
|
||||||
|
|
||||||
from bec_widgets.utils.colors import apply_theme
|
|
||||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
|
|
||||||
DeviceComboBox,
|
|
||||||
)
|
|
||||||
|
|
||||||
app = QApplication([])
|
|
||||||
apply_theme("dark")
|
|
||||||
widget = QWidget()
|
|
||||||
widget.setFixedSize(200, 200)
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
widget.setLayout(layout)
|
|
||||||
device_line_edit = DeviceComboBox()
|
|
||||||
device_line_edit.filter_to_positioner = True
|
|
||||||
signal_line_edit = SignalLineEdit()
|
|
||||||
device_line_edit.device_selected.connect(signal_line_edit.set_device)
|
|
||||||
|
|
||||||
layout.addWidget(device_line_edit)
|
|
||||||
layout.addWidget(signal_line_edit)
|
|
||||||
widget.show()
|
|
||||||
app.exec_()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{'files': ['signal_line_edit.py']}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# Copyright (C) 2022 The Qt Company Ltd.
|
|
||||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
|
||||||
|
|
||||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
|
||||||
from qtpy.QtWidgets import QWidget
|
|
||||||
|
|
||||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
|
||||||
from bec_widgets.widgets.control.device_input.signal_line_edit.signal_line_edit import (
|
|
||||||
SignalLineEdit,
|
|
||||||
)
|
|
||||||
|
|
||||||
DOM_XML = """
|
|
||||||
<ui language='c++'>
|
|
||||||
<widget class='SignalLineEdit' name='signal_line_edit'>
|
|
||||||
</widget>
|
|
||||||
</ui>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class SignalLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self._form_editor = None
|
|
||||||
|
|
||||||
def createWidget(self, parent):
|
|
||||||
if parent is None:
|
|
||||||
return QWidget()
|
|
||||||
t = SignalLineEdit(parent)
|
|
||||||
return t
|
|
||||||
|
|
||||||
def domXml(self):
|
|
||||||
return DOM_XML
|
|
||||||
|
|
||||||
def group(self):
|
|
||||||
return "BEC Input Widgets"
|
|
||||||
|
|
||||||
def icon(self):
|
|
||||||
return designer_material_icon(SignalLineEdit.ICON_NAME)
|
|
||||||
|
|
||||||
def includeFile(self):
|
|
||||||
return "signal_line_edit"
|
|
||||||
|
|
||||||
def initialize(self, form_editor):
|
|
||||||
self._form_editor = form_editor
|
|
||||||
|
|
||||||
def isContainer(self):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def isInitialized(self):
|
|
||||||
return self._form_editor is not None
|
|
||||||
|
|
||||||
def name(self):
|
|
||||||
return "SignalLineEdit"
|
|
||||||
|
|
||||||
def toolTip(self):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def whatsThis(self):
|
|
||||||
return self.toolTip()
|
|
||||||
@@ -14,19 +14,18 @@ from qtpy.QtWidgets import (
|
|||||||
QLabel,
|
QLabel,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QSpacerItem,
|
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from bec_widgets.utils import ConnectionConfig
|
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
from bec_widgets.utils.colors import apply_theme, get_accent_colors
|
from bec_widgets.utils.colors import apply_theme, get_accent_colors
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
|
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
|
||||||
from bec_widgets.widgets.control.scan_control.scan_group_box import ScanGroupBox
|
from bec_widgets.widgets.control.scan_control.scan_group_box import ScanGroupBox
|
||||||
|
from bec_widgets.widgets.control.scan_control.scan_info_adapter import ScanInfoAdapter
|
||||||
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
|
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
|
||||||
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
|
||||||
|
|
||||||
|
|
||||||
class ScanParameterConfig(BaseModel):
|
class ScanParameterConfig(BaseModel):
|
||||||
@@ -84,7 +83,6 @@ class ScanControl(BECWidget, QWidget):
|
|||||||
self.kwarg_boxes = []
|
self.kwarg_boxes = []
|
||||||
self.expert_mode = False # TODO implement in the future versions
|
self.expert_mode = False # TODO implement in the future versions
|
||||||
self.previous_scan = None
|
self.previous_scan = None
|
||||||
self.last_scan_found = None
|
|
||||||
|
|
||||||
# Widget Default Parameters
|
# Widget Default Parameters
|
||||||
self.config.default_scan = default_scan
|
self.config.default_scan = default_scan
|
||||||
@@ -97,6 +95,7 @@ class ScanControl(BECWidget, QWidget):
|
|||||||
self._hide_scan_control_buttons = False
|
self._hide_scan_control_buttons = False
|
||||||
self._hide_metadata = False
|
self._hide_metadata = False
|
||||||
self._hide_scan_selection_combobox = False
|
self._hide_scan_selection_combobox = False
|
||||||
|
self._scan_info_adapter = ScanInfoAdapter()
|
||||||
|
|
||||||
# Create and set main layout
|
# Create and set main layout
|
||||||
self._init_UI()
|
self._init_UI()
|
||||||
@@ -123,17 +122,12 @@ class ScanControl(BECWidget, QWidget):
|
|||||||
scan_selection_layout.addWidget(self.comboBox_scan_selection, 1)
|
scan_selection_layout.addWidget(self.comboBox_scan_selection, 1)
|
||||||
self.scan_selection_group.layout().addLayout(scan_selection_layout)
|
self.scan_selection_group.layout().addLayout(scan_selection_layout)
|
||||||
|
|
||||||
# Label to reload the last scan parameters within scan selection group box
|
# Button to reload the last scan parameters on demand.
|
||||||
self.toggle_layout = QHBoxLayout()
|
self.last_scan_button = QPushButton(
|
||||||
self.toggle_layout.addSpacerItem(
|
"Restore last scan parameters", self.scan_selection_group
|
||||||
QSpacerItem(0, 0, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
|
||||||
)
|
)
|
||||||
self.last_scan_label = QLabel("Restore last scan parameters", self.scan_selection_group)
|
self.last_scan_button.clicked.connect(self.request_last_executed_scan_parameters)
|
||||||
self.toggle = ToggleSwitch(parent=self.scan_selection_group, checked=False)
|
self.scan_selection_group.layout().addWidget(self.last_scan_button)
|
||||||
self.toggle.enabled.connect(self.request_last_executed_scan_parameters)
|
|
||||||
self.toggle_layout.addWidget(self.last_scan_label)
|
|
||||||
self.toggle_layout.addWidget(self.toggle)
|
|
||||||
self.scan_selection_group.layout().addLayout(self.toggle_layout)
|
|
||||||
self.scan_selection_group.setSizePolicy(
|
self.scan_selection_group.setSizePolicy(
|
||||||
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed
|
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed
|
||||||
)
|
)
|
||||||
@@ -191,12 +185,17 @@ class ScanControl(BECWidget, QWidget):
|
|||||||
MessageEndpoints.available_scans()
|
MessageEndpoints.available_scans()
|
||||||
).resource
|
).resource
|
||||||
if self.config.allowed_scans is None:
|
if self.config.allowed_scans is None:
|
||||||
supported_scans = ["ScanBase", "SyncFlyScanBase", "AsyncFlyScanBase"]
|
supported_scans = ["ScanBase", "SyncFlyScanBase", "AsyncFlyScanBase", "ScanBaseV4"]
|
||||||
allowed_scans = [
|
|
||||||
scan_name
|
def _is_scan_supported(scan_name):
|
||||||
for scan_name, scan_info in self.available_scans.items()
|
scan_info = self.available_scans[scan_name]
|
||||||
if scan_info["base_class"] in supported_scans and len(scan_info["gui_config"]) > 0
|
return (
|
||||||
]
|
scan_info.get("base_class") in supported_scans
|
||||||
|
and self._scan_info_adapter.has_scan_ui_config(scan_info)
|
||||||
|
and not scan_name.startswith("_")
|
||||||
|
)
|
||||||
|
|
||||||
|
allowed_scans = filter(_is_scan_supported, self.available_scans.keys())
|
||||||
|
|
||||||
else:
|
else:
|
||||||
allowed_scans = self.config.allowed_scans
|
allowed_scans = self.config.allowed_scans
|
||||||
@@ -206,7 +205,6 @@ class ScanControl(BECWidget, QWidget):
|
|||||||
"""Callback for scan selection combo box"""
|
"""Callback for scan selection combo box"""
|
||||||
selected_scan_name = self.comboBox_scan_selection.currentText()
|
selected_scan_name = self.comboBox_scan_selection.currentText()
|
||||||
self.scan_selected.emit(selected_scan_name)
|
self.scan_selected.emit(selected_scan_name)
|
||||||
self.request_last_executed_scan_parameters()
|
|
||||||
self.restore_scan_parameters(selected_scan_name)
|
self.restore_scan_parameters(selected_scan_name)
|
||||||
|
|
||||||
@SafeSlot()
|
@SafeSlot()
|
||||||
@@ -215,10 +213,6 @@ class ScanControl(BECWidget, QWidget):
|
|||||||
"""
|
"""
|
||||||
Requests the last executed scan parameters from BEC and restores them to the scan control widget.
|
Requests the last executed scan parameters from BEC and restores them to the scan control widget.
|
||||||
"""
|
"""
|
||||||
self.last_scan_found = False
|
|
||||||
if not self.toggle.checked:
|
|
||||||
return
|
|
||||||
|
|
||||||
current_scan = self.comboBox_scan_selection.currentText()
|
current_scan = self.comboBox_scan_selection.currentText()
|
||||||
history = (
|
history = (
|
||||||
self.client.connector.xread(
|
self.client.connector.xread(
|
||||||
@@ -246,8 +240,6 @@ class ScanControl(BECWidget, QWidget):
|
|||||||
if merged and self.kwarg_boxes:
|
if merged and self.kwarg_boxes:
|
||||||
for box in self.kwarg_boxes:
|
for box in self.kwarg_boxes:
|
||||||
box.set_parameters(merged)
|
box.set_parameters(merged)
|
||||||
|
|
||||||
self.last_scan_found = True
|
|
||||||
break
|
break
|
||||||
|
|
||||||
@SafeProperty(str)
|
@SafeProperty(str)
|
||||||
@@ -390,14 +382,14 @@ class ScanControl(BECWidget, QWidget):
|
|||||||
self.reset_layout()
|
self.reset_layout()
|
||||||
selected_scan_info = self.available_scans.get(scan_name, {})
|
selected_scan_info = self.available_scans.get(scan_name, {})
|
||||||
|
|
||||||
gui_config = selected_scan_info.get("gui_config", {})
|
gui_config = self._scan_info_adapter.build_scan_ui_config(selected_scan_info)
|
||||||
self.arg_group = gui_config.get("arg_group", None)
|
arg_group = gui_config.get("arg_group", None)
|
||||||
self.kwarg_groups = gui_config.get("kwarg_groups", None)
|
kwarg_groups = gui_config.get("kwarg_groups", [])
|
||||||
|
|
||||||
if bool(self.arg_group["arg_inputs"]):
|
if arg_group and bool(arg_group.get("arg_inputs")):
|
||||||
self.add_arg_group(self.arg_group)
|
self.add_arg_group(arg_group)
|
||||||
if len(self.kwarg_groups) > 0:
|
if kwarg_groups:
|
||||||
self.add_kwargs_boxes(self.kwarg_groups)
|
self.add_kwargs_boxes(kwarg_groups)
|
||||||
|
|
||||||
self.update()
|
self.update()
|
||||||
self.adjustSize()
|
self.adjustSize()
|
||||||
@@ -428,6 +420,7 @@ class ScanControl(BECWidget, QWidget):
|
|||||||
position = self.ARG_BOX_POSITION + (1 if self.arg_box is not None else 0)
|
position = self.ARG_BOX_POSITION + (1 if self.arg_box is not None else 0)
|
||||||
for group in groups:
|
for group in groups:
|
||||||
box = ScanGroupBox(box_type="kwargs", config=group)
|
box = ScanGroupBox(box_type="kwargs", config=group)
|
||||||
|
box.reference_units_changed.connect(self._apply_reference_units_to_other_boxes)
|
||||||
box.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
box.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||||
self.layout.insertWidget(position + len(self.kwarg_boxes), box)
|
self.layout.insertWidget(position + len(self.kwarg_boxes), box)
|
||||||
self.kwarg_boxes.append(box)
|
self.kwarg_boxes.append(box)
|
||||||
@@ -441,11 +434,30 @@ class ScanControl(BECWidget, QWidget):
|
|||||||
"""
|
"""
|
||||||
self.arg_box = ScanGroupBox(box_type="args", config=group)
|
self.arg_box = ScanGroupBox(box_type="args", config=group)
|
||||||
self.arg_box.device_selected.connect(self.emit_device_selected)
|
self.arg_box.device_selected.connect(self.emit_device_selected)
|
||||||
|
self.arg_box.reference_units_changed.connect(self._apply_reference_units_to_other_boxes)
|
||||||
self.arg_box.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
self.arg_box.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||||
self.arg_box.hide_add_remove_buttons = self._hide_add_remove_buttons
|
self.arg_box.hide_add_remove_buttons = self._hide_add_remove_buttons
|
||||||
self.layout.insertWidget(self.ARG_BOX_POSITION, self.arg_box)
|
self.layout.insertWidget(self.ARG_BOX_POSITION, self.arg_box)
|
||||||
self.arg_box.setVisible(not self._hide_arg_box)
|
self.arg_box.setVisible(not self._hide_arg_box)
|
||||||
|
|
||||||
|
def _scan_group_boxes(self) -> list[ScanGroupBox]:
|
||||||
|
boxes = []
|
||||||
|
if self.arg_box is not None:
|
||||||
|
boxes.append(self.arg_box)
|
||||||
|
boxes.extend(self.kwarg_boxes)
|
||||||
|
return boxes
|
||||||
|
|
||||||
|
def _apply_reference_units_to_other_boxes(
|
||||||
|
self, source_box: ScanGroupBox, reference_name: str, units: str | None
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Propagate device-derived units to scan fields that reference a device in another group.
|
||||||
|
"""
|
||||||
|
for box in self._scan_group_boxes():
|
||||||
|
if box is source_box:
|
||||||
|
continue
|
||||||
|
box.apply_reference_units(reference_name, units)
|
||||||
|
|
||||||
@SafeSlot(str)
|
@SafeSlot(str)
|
||||||
def emit_device_selected(self, dev_names):
|
def emit_device_selected(self, dev_names):
|
||||||
"""
|
"""
|
||||||
@@ -496,8 +508,6 @@ class ScanControl(BECWidget, QWidget):
|
|||||||
Args:
|
Args:
|
||||||
scan_name(str): Name of the scan to restore the parameters for.
|
scan_name(str): Name of the scan to restore the parameters for.
|
||||||
"""
|
"""
|
||||||
if self.last_scan_found is True:
|
|
||||||
return
|
|
||||||
scan_params = self.config.scans.get(scan_name, None)
|
scan_params = self.config.scans.get(scan_name, None)
|
||||||
if scan_params is None and self.previous_scan is None:
|
if scan_params is None and self.previous_scan is None:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ from qtpy.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from bec_widgets.utils.widget_io import WidgetIO
|
from bec_widgets.utils.widget_io import WidgetIO
|
||||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
|
||||||
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
|
BECDeviceFilter,
|
||||||
DeviceLineEdit,
|
DeviceComboBox,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
@@ -164,8 +164,8 @@ class ScanCheckBox(QCheckBox):
|
|||||||
|
|
||||||
class ScanGroupBox(QGroupBox):
|
class ScanGroupBox(QGroupBox):
|
||||||
WIDGET_HANDLER = {
|
WIDGET_HANDLER = {
|
||||||
ScanArgType.DEVICE: DeviceLineEdit,
|
ScanArgType.DEVICE: DeviceComboBox,
|
||||||
ScanArgType.DEVICEBASE: DeviceLineEdit,
|
ScanArgType.DEVICEBASE: DeviceComboBox,
|
||||||
ScanArgType.FLOAT: ScanDoubleSpinBox,
|
ScanArgType.FLOAT: ScanDoubleSpinBox,
|
||||||
ScanArgType.INT: ScanSpinBox,
|
ScanArgType.INT: ScanSpinBox,
|
||||||
ScanArgType.BOOL: ScanCheckBox,
|
ScanArgType.BOOL: ScanCheckBox,
|
||||||
@@ -174,6 +174,7 @@ class ScanGroupBox(QGroupBox):
|
|||||||
}
|
}
|
||||||
|
|
||||||
device_selected = Signal(str)
|
device_selected = Signal(str)
|
||||||
|
reference_units_changed = Signal(object, str, object)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -209,6 +210,8 @@ class ScanGroupBox(QGroupBox):
|
|||||||
|
|
||||||
self.labels = []
|
self.labels = []
|
||||||
self.widgets = []
|
self.widgets = []
|
||||||
|
self._widget_configs = {}
|
||||||
|
self._column_labels = {}
|
||||||
self.selected_devices = {}
|
self.selected_devices = {}
|
||||||
|
|
||||||
self.init_box(self.config)
|
self.init_box(self.config)
|
||||||
@@ -247,6 +250,7 @@ class ScanGroupBox(QGroupBox):
|
|||||||
label = QLabel(text=display_name)
|
label = QLabel(text=display_name)
|
||||||
self.layout.addWidget(label, row, column_index)
|
self.layout.addWidget(label, row, column_index)
|
||||||
self.labels.append(label)
|
self.labels.append(label)
|
||||||
|
self._column_labels[column_index] = label
|
||||||
|
|
||||||
def add_input_widgets(self, group_inputs: dict, row) -> None:
|
def add_input_widgets(self, group_inputs: dict, row) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -271,22 +275,41 @@ class ScanGroupBox(QGroupBox):
|
|||||||
continue
|
continue
|
||||||
if default == "_empty":
|
if default == "_empty":
|
||||||
default = None
|
default = None
|
||||||
widget = widget_class(parent=self.parent(), arg_name=arg_name, default=default)
|
if widget_class is DeviceComboBox:
|
||||||
if isinstance(widget, DeviceLineEdit):
|
widget = widget_class(
|
||||||
widget.set_device_filter(BECDeviceFilter.DEVICE)
|
parent=self.parent(),
|
||||||
|
arg_name=arg_name,
|
||||||
|
default=default,
|
||||||
|
device_filter=BECDeviceFilter.DEVICE,
|
||||||
|
autocomplete=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
widget = widget_class(parent=self.parent(), arg_name=arg_name, default=default)
|
||||||
|
self._apply_numeric_precision(widget, item)
|
||||||
|
self._apply_numeric_limits(widget, item)
|
||||||
|
if isinstance(widget, DeviceComboBox):
|
||||||
self.selected_devices[widget] = ""
|
self.selected_devices[widget] = ""
|
||||||
widget.device_selected.connect(self.emit_device_selected)
|
widget.device_selected.connect(self.emit_device_selected)
|
||||||
|
widget.currentTextChanged.connect(
|
||||||
|
lambda text, device_widget=widget: self._handle_device_text_changed(
|
||||||
|
device_widget, text
|
||||||
|
)
|
||||||
|
)
|
||||||
if isinstance(widget, ScanLiteralsComboBox):
|
if isinstance(widget, ScanLiteralsComboBox):
|
||||||
widget.set_literals(item["type"].get("Literal", []))
|
widget.set_literals(item["type"].get("Literal", []))
|
||||||
tooltip = item.get("tooltip", None)
|
self._widget_configs[widget] = item
|
||||||
if tooltip is not None:
|
self._apply_unit_metadata(widget, item)
|
||||||
widget.setToolTip(item["tooltip"])
|
|
||||||
self.layout.addWidget(widget, row, column_index)
|
self.layout.addWidget(widget, row, column_index)
|
||||||
self.widgets.append(widget)
|
self.widgets.append(widget)
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
def emit_device_selected(self, device_name):
|
def emit_device_selected(self, device_name):
|
||||||
self.selected_devices[self.sender()] = device_name.strip()
|
sender = self.sender()
|
||||||
|
self.selected_devices[sender] = device_name.strip()
|
||||||
|
if isinstance(sender, DeviceComboBox):
|
||||||
|
units = self._device_units(sender.get_current_device())
|
||||||
|
self._update_reference_units(sender, units)
|
||||||
|
self._emit_reference_units_changed(sender, units)
|
||||||
selected_devices_str = " ".join(self.selected_devices.values())
|
selected_devices_str = " ".join(self.selected_devices.values())
|
||||||
self.device_selected.emit(selected_devices_str)
|
self.device_selected.emit(selected_devices_str)
|
||||||
|
|
||||||
@@ -311,8 +334,9 @@ class ScanGroupBox(QGroupBox):
|
|||||||
return
|
return
|
||||||
|
|
||||||
for widget in self.widgets[-len(self.inputs) :]:
|
for widget in self.widgets[-len(self.inputs) :]:
|
||||||
if isinstance(widget, DeviceLineEdit):
|
if isinstance(widget, DeviceComboBox):
|
||||||
self.selected_devices[widget] = ""
|
self.selected_devices[widget] = ""
|
||||||
|
self._widget_configs.pop(widget, None)
|
||||||
widget.close()
|
widget.close()
|
||||||
widget.deleteLater()
|
widget.deleteLater()
|
||||||
self.widgets = self.widgets[: -len(self.inputs)]
|
self.widgets = self.widgets[: -len(self.inputs)]
|
||||||
@@ -323,8 +347,9 @@ class ScanGroupBox(QGroupBox):
|
|||||||
def remove_all_widget_bundles(self):
|
def remove_all_widget_bundles(self):
|
||||||
"""Remove every widget bundle from the scan control layout."""
|
"""Remove every widget bundle from the scan control layout."""
|
||||||
for widget in list(self.widgets):
|
for widget in list(self.widgets):
|
||||||
if isinstance(widget, DeviceLineEdit):
|
if isinstance(widget, DeviceComboBox):
|
||||||
self.selected_devices.pop(widget, None)
|
self.selected_devices.pop(widget, None)
|
||||||
|
self._widget_configs.pop(widget, None)
|
||||||
widget.close()
|
widget.close()
|
||||||
widget.deleteLater()
|
widget.deleteLater()
|
||||||
self.layout.removeWidget(widget)
|
self.layout.removeWidget(widget)
|
||||||
@@ -360,8 +385,10 @@ class ScanGroupBox(QGroupBox):
|
|||||||
for j in range(self.layout.columnCount()):
|
for j in range(self.layout.columnCount()):
|
||||||
try: # In case that the bundle size changes
|
try: # In case that the bundle size changes
|
||||||
widget = self.layout.itemAtPosition(i, j).widget()
|
widget = self.layout.itemAtPosition(i, j).widget()
|
||||||
if isinstance(widget, DeviceLineEdit) and device_object:
|
if isinstance(widget, DeviceComboBox) and device_object:
|
||||||
value = widget.get_current_device()
|
value = widget.get_current_device()
|
||||||
|
elif isinstance(widget, DeviceComboBox):
|
||||||
|
value = widget.currentText()
|
||||||
else:
|
else:
|
||||||
value = WidgetIO.get_value(widget)
|
value = WidgetIO.get_value(widget)
|
||||||
args.append(value)
|
args.append(value)
|
||||||
@@ -373,8 +400,10 @@ class ScanGroupBox(QGroupBox):
|
|||||||
kwargs = {}
|
kwargs = {}
|
||||||
for i in range(self.layout.columnCount()):
|
for i in range(self.layout.columnCount()):
|
||||||
widget = self.layout.itemAtPosition(1, i).widget()
|
widget = self.layout.itemAtPosition(1, i).widget()
|
||||||
if isinstance(widget, DeviceLineEdit) and device_object:
|
if isinstance(widget, DeviceComboBox) and device_object:
|
||||||
value = widget.get_current_device().name
|
value = widget.get_current_device().name
|
||||||
|
elif isinstance(widget, DeviceComboBox):
|
||||||
|
value = widget.currentText()
|
||||||
elif isinstance(widget, ScanLiteralsComboBox):
|
elif isinstance(widget, ScanLiteralsComboBox):
|
||||||
value = widget.get_value()
|
value = widget.get_value()
|
||||||
else:
|
else:
|
||||||
@@ -390,7 +419,7 @@ class ScanGroupBox(QGroupBox):
|
|||||||
if item is not None:
|
if item is not None:
|
||||||
widget = item.widget()
|
widget = item.widget()
|
||||||
if widget is not None:
|
if widget is not None:
|
||||||
if isinstance(widget, DeviceLineEdit):
|
if isinstance(widget, DeviceComboBox):
|
||||||
widget_rows += 1
|
widget_rows += 1
|
||||||
return widget_rows
|
return widget_rows
|
||||||
|
|
||||||
@@ -423,3 +452,159 @@ class ScanGroupBox(QGroupBox):
|
|||||||
if widget.arg_name == key:
|
if widget.arg_name == key:
|
||||||
WidgetIO.set_value(widget, value)
|
WidgetIO.set_value(widget, value)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _unit_tooltip(item: dict, units: str | None = None) -> str | None:
|
||||||
|
tooltip = item.get("tooltip", None)
|
||||||
|
reference_units = item.get("reference_units", None)
|
||||||
|
units = units or item.get("units", None)
|
||||||
|
tooltip_parts = [tooltip] if tooltip else []
|
||||||
|
if units:
|
||||||
|
tooltip_parts.append(f"Units: {units}")
|
||||||
|
elif reference_units:
|
||||||
|
tooltip_parts.append(f"Units from: {reference_units}")
|
||||||
|
if tooltip_parts:
|
||||||
|
return "\n".join(tooltip_parts)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _apply_unit_metadata(self, widget, item: dict, units: str | None = None) -> None:
|
||||||
|
units = units or item.get("units", None)
|
||||||
|
tooltip = self._unit_tooltip(item, units)
|
||||||
|
existing_tooltip = widget.toolTip()
|
||||||
|
|
||||||
|
if existing_tooltip:
|
||||||
|
# strip the existing unit info from the tooltip if it exists
|
||||||
|
# to avoid tooltip bloat on multiple updates
|
||||||
|
existing_tooltip = "\n".join(
|
||||||
|
line
|
||||||
|
for line in existing_tooltip.splitlines()
|
||||||
|
if not (line.startswith("Units:") or line.startswith("Units from:"))
|
||||||
|
).strip()
|
||||||
|
if tooltip:
|
||||||
|
if existing_tooltip:
|
||||||
|
widget.setToolTip(f"{existing_tooltip}\n{tooltip}")
|
||||||
|
else:
|
||||||
|
widget.setToolTip(tooltip)
|
||||||
|
if hasattr(widget, "setSuffix"):
|
||||||
|
widget.setSuffix(f" {units}" if units else "")
|
||||||
|
|
||||||
|
def _refresh_column_label(self, column: int, item: dict) -> None:
|
||||||
|
if column not in self._column_labels:
|
||||||
|
return
|
||||||
|
self._column_labels[column].setText(item.get("display_name", item.get("name", None)))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _device_units(device) -> str | None:
|
||||||
|
egu = getattr(device, "egu", None)
|
||||||
|
if not callable(egu):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return egu()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to fetch engineering units from device %s", device)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _widget_position(self, widget) -> tuple[int, int] | None:
|
||||||
|
for row in range(self.layout.rowCount()):
|
||||||
|
for column in range(self.layout.columnCount()):
|
||||||
|
item = self.layout.itemAtPosition(row, column)
|
||||||
|
if item is not None and item.widget() is widget:
|
||||||
|
return row, column
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _update_reference_units(self, device_widget: DeviceComboBox, units: str | None) -> None:
|
||||||
|
position = self._widget_position(device_widget)
|
||||||
|
if position is None:
|
||||||
|
return
|
||||||
|
source_row, _ = position
|
||||||
|
source_name = device_widget.arg_name
|
||||||
|
|
||||||
|
for widget in self.widgets:
|
||||||
|
item = self._widget_configs.get(widget, {})
|
||||||
|
if item.get("reference_units") != source_name:
|
||||||
|
continue
|
||||||
|
widget_position = self._widget_position(widget)
|
||||||
|
if widget_position is None:
|
||||||
|
continue
|
||||||
|
row, column = widget_position
|
||||||
|
if self.box_type == "args" and row != source_row:
|
||||||
|
continue
|
||||||
|
self._apply_unit_metadata(widget, item, units)
|
||||||
|
self._refresh_column_label(column, item)
|
||||||
|
|
||||||
|
def apply_reference_units(self, reference_name: str, units: str | None) -> None:
|
||||||
|
"""
|
||||||
|
Apply units to widgets that reference an argument owned by another group box.
|
||||||
|
|
||||||
|
Cross-box references only have one widget row, so row scoping is intentionally handled by
|
||||||
|
the source group before this method is called.
|
||||||
|
"""
|
||||||
|
for widget in self.widgets:
|
||||||
|
item = self._widget_configs.get(widget, {})
|
||||||
|
if item.get("reference_units") != reference_name:
|
||||||
|
continue
|
||||||
|
self._apply_unit_metadata(widget, item, units)
|
||||||
|
position = self._widget_position(widget)
|
||||||
|
if position is not None:
|
||||||
|
_, column = position
|
||||||
|
self._refresh_column_label(column, item)
|
||||||
|
|
||||||
|
def _emit_reference_units_changed(
|
||||||
|
self, device_widget: DeviceComboBox, units: str | None
|
||||||
|
) -> None:
|
||||||
|
reference_name = getattr(device_widget, "arg_name", None)
|
||||||
|
if not reference_name:
|
||||||
|
return
|
||||||
|
self.reference_units_changed.emit(self, reference_name, units)
|
||||||
|
|
||||||
|
def _handle_device_text_changed(self, device_widget: DeviceComboBox, device_name: str) -> None:
|
||||||
|
if not device_widget.validate_device(device_name):
|
||||||
|
self.selected_devices[device_widget] = ""
|
||||||
|
self._update_reference_units(device_widget, None)
|
||||||
|
self._emit_reference_units_changed(device_widget, None)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _apply_numeric_precision(widget: ScanDoubleSpinBox, item: dict) -> None:
|
||||||
|
if not isinstance(widget, ScanDoubleSpinBox):
|
||||||
|
return
|
||||||
|
|
||||||
|
precision = item.get("precision")
|
||||||
|
if precision is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
widget.setDecimals(max(0, int(precision)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logger.warning(
|
||||||
|
"Ignoring invalid precision %r for parameter %s", precision, item.get("name")
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _apply_numeric_limits(widget: ScanDoubleSpinBox | ScanSpinBox, item: dict) -> None:
|
||||||
|
if isinstance(widget, ScanSpinBox):
|
||||||
|
minimum = -2147483647 # largest int which qt allows
|
||||||
|
maximum = 2147483647
|
||||||
|
if item.get("ge") is not None:
|
||||||
|
minimum = int(item["ge"])
|
||||||
|
if item.get("gt") is not None:
|
||||||
|
minimum = int(item["gt"]) + 1
|
||||||
|
if item.get("le") is not None:
|
||||||
|
maximum = int(item["le"])
|
||||||
|
if item.get("lt") is not None:
|
||||||
|
maximum = int(item["lt"]) - 1
|
||||||
|
widget.setRange(minimum, maximum)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(widget, ScanDoubleSpinBox):
|
||||||
|
minimum = -float("inf")
|
||||||
|
maximum = float("inf")
|
||||||
|
step = 10 ** (-widget.decimals())
|
||||||
|
if item.get("ge") is not None:
|
||||||
|
minimum = float(item["ge"])
|
||||||
|
if item.get("gt") is not None:
|
||||||
|
minimum = float(item["gt"]) + step
|
||||||
|
if item.get("le") is not None:
|
||||||
|
maximum = float(item["le"])
|
||||||
|
if item.get("lt") is not None:
|
||||||
|
maximum = float(item["lt"]) - step
|
||||||
|
widget.setRange(minimum, maximum)
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
"""Helpers for translating BEC scan metadata into ScanControl UI configuration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
AnnotationValue = str | dict[str, Any] | list[Any] | None
|
||||||
|
ScanArgumentMetadata = dict[str, Any]
|
||||||
|
SignatureEntry = dict[str, Any]
|
||||||
|
ScanInputConfig = dict[str, Any]
|
||||||
|
ScanInfo = dict[str, Any]
|
||||||
|
ScanUIConfig = dict[str, Any]
|
||||||
|
|
||||||
|
SUPPORTED_SCAN_INPUT_TYPES = {"device", "DeviceBase", "float", "int", "bool", "str"}
|
||||||
|
|
||||||
|
|
||||||
|
class ScanInfoAdapter:
|
||||||
|
"""Normalize available-scan payloads into the structure consumed by ``ScanControl``."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def has_scan_ui_config(scan_info: ScanInfo) -> bool:
|
||||||
|
"""Check whether a scan exposes enough metadata to build a UI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scan_info (ScanInfo): Available-scan payload for one scan.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: ``True`` when a supported GUI metadata field is present.
|
||||||
|
"""
|
||||||
|
if not (
|
||||||
|
scan_info.get("gui_visibility")
|
||||||
|
or scan_info.get("gui_config")
|
||||||
|
or scan_info.get("gui_visualization")
|
||||||
|
or scan_info.get("signature")
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
gui_config = ScanInfoAdapter().build_scan_ui_config(scan_info)
|
||||||
|
return not ScanInfoAdapter.unsupported_inputs(gui_config)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_supported_input_type(input_type: AnnotationValue) -> bool:
|
||||||
|
"""Return whether ``ScanGroupBox`` has a widget for this serialized type."""
|
||||||
|
return (
|
||||||
|
isinstance(input_type, str)
|
||||||
|
and input_type in SUPPORTED_SCAN_INPUT_TYPES
|
||||||
|
or isinstance(input_type, dict)
|
||||||
|
and "Literal" in input_type
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def unsupported_inputs(gui_config: ScanUIConfig) -> list[ScanInputConfig]:
|
||||||
|
"""Return input configs that cannot be rendered by ``ScanGroupBox``."""
|
||||||
|
inputs = []
|
||||||
|
arg_group = gui_config.get("arg_group")
|
||||||
|
if arg_group:
|
||||||
|
inputs.extend(arg_group.get("inputs", []))
|
||||||
|
for group in gui_config.get("kwarg_groups", []):
|
||||||
|
inputs.extend(group.get("inputs", []))
|
||||||
|
return [
|
||||||
|
input_config
|
||||||
|
for input_config in inputs
|
||||||
|
if not ScanInfoAdapter.is_supported_input_type(input_config.get("type"))
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_display_name(name: str) -> str:
|
||||||
|
"""Convert a parameter name into a user-facing label.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name (str): Raw parameter name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Formatted display label such as ``Exp Time``.
|
||||||
|
"""
|
||||||
|
parts = re.split(r"(_|\d+)", name)
|
||||||
|
return " ".join(part.capitalize() for part in parts if part.isalnum()).strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_tooltip(scan_argument: ScanArgumentMetadata) -> str | None:
|
||||||
|
"""Resolve the tooltip text from parsed ``ScanArgument`` metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scan_argument (ScanArgumentMetadata): Parsed ``ScanArgument`` metadata.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str | None: Explicit tooltip text if provided, otherwise the description fallback.
|
||||||
|
"""
|
||||||
|
return scan_argument.get("tooltip") or scan_argument.get("description")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_annotation(
|
||||||
|
annotation: AnnotationValue,
|
||||||
|
) -> tuple[AnnotationValue, ScanArgumentMetadata]:
|
||||||
|
"""Extract the serialized base annotation and ``ScanArgument`` metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
annotation (AnnotationValue): Serialized annotation payload from BEC.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[AnnotationValue, ScanArgumentMetadata]: The unwrapped annotation and parsed
|
||||||
|
``ScanArgument`` metadata.
|
||||||
|
"""
|
||||||
|
scan_argument: ScanArgumentMetadata = {}
|
||||||
|
if isinstance(annotation, list):
|
||||||
|
annotation = next(
|
||||||
|
(entry for entry in annotation if entry != "NoneType"),
|
||||||
|
annotation[0] if annotation else "_empty",
|
||||||
|
)
|
||||||
|
if isinstance(annotation, dict) and "Annotated" in annotation:
|
||||||
|
annotated = annotation["Annotated"]
|
||||||
|
annotation = annotated.get("type", "_empty")
|
||||||
|
scan_argument = annotated.get("metadata", {}).get("ScanArgument", {}) or {}
|
||||||
|
return annotation, scan_argument
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def scan_arg_type_from_annotation(annotation: AnnotationValue) -> AnnotationValue:
|
||||||
|
"""Normalize an annotation value to the widget type expected by ``ScanControl``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
annotation (AnnotationValue): Serialized or parsed annotation value.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AnnotationValue: The normalized type identifier used by the widget layer.
|
||||||
|
"""
|
||||||
|
if isinstance(annotation, dict):
|
||||||
|
return annotation
|
||||||
|
if annotation in ("_empty", None):
|
||||||
|
return "str"
|
||||||
|
return annotation
|
||||||
|
|
||||||
|
def scan_input_from_signature(
|
||||||
|
self, param: SignatureEntry, arg: bool = False
|
||||||
|
) -> ScanInputConfig:
|
||||||
|
"""Build one ScanControl input description from a signature entry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
param (SignatureEntry): Serialized signature entry.
|
||||||
|
arg (bool): Whether the parameter belongs to the positional arg bundle.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ScanInputConfig: Normalized input configuration for ``ScanControl``.
|
||||||
|
"""
|
||||||
|
annotation, scan_argument = self.parse_annotation(param.get("annotation"))
|
||||||
|
return self._build_scan_input(
|
||||||
|
name=param["name"],
|
||||||
|
annotation=annotation,
|
||||||
|
scan_argument=scan_argument,
|
||||||
|
arg=arg,
|
||||||
|
default=None if arg else param.get("default", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
def scan_input_from_arg_input(
|
||||||
|
self, name: str, item_type: AnnotationValue, signature_by_name: dict[str, SignatureEntry]
|
||||||
|
) -> ScanInputConfig:
|
||||||
|
"""Build one arg-bundle input description from ``arg_input`` metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name (str): Argument name from ``arg_input``.
|
||||||
|
item_type (AnnotationValue): Serialized argument type from ``arg_input``.
|
||||||
|
signature_by_name (dict[str, SignatureEntry]): Signature entries indexed by
|
||||||
|
parameter name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ScanInputConfig: Normalized input configuration for one arg-bundle field.
|
||||||
|
"""
|
||||||
|
if name in signature_by_name:
|
||||||
|
scan_input = self.scan_input_from_signature(signature_by_name[name], arg=True)
|
||||||
|
scan_input["type"] = self.scan_arg_type_from_annotation(
|
||||||
|
self.parse_annotation(signature_by_name[name].get("annotation"))[0]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
annotation, scan_argument = self.parse_annotation(item_type)
|
||||||
|
scan_input = self._build_scan_input(
|
||||||
|
name=name,
|
||||||
|
annotation=annotation,
|
||||||
|
scan_argument=scan_argument,
|
||||||
|
arg=True,
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
if scan_input["type"] in ("_empty", None):
|
||||||
|
scan_input["type"] = item_type
|
||||||
|
return scan_input
|
||||||
|
|
||||||
|
def _build_scan_input(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
annotation: AnnotationValue,
|
||||||
|
scan_argument: ScanArgumentMetadata,
|
||||||
|
*,
|
||||||
|
arg: bool,
|
||||||
|
default: Any,
|
||||||
|
) -> ScanInputConfig:
|
||||||
|
"""Build one normalized ScanControl input configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name (str): Parameter name.
|
||||||
|
annotation (AnnotationValue): Parsed annotation value.
|
||||||
|
scan_argument (ScanArgumentMetadata): Parsed ``ScanArgument`` metadata.
|
||||||
|
arg (bool): Whether the parameter belongs to the positional arg bundle.
|
||||||
|
default (Any): Default value for the parameter.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ScanInputConfig: Normalized input configuration.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"arg": arg,
|
||||||
|
"name": name,
|
||||||
|
"type": self.scan_arg_type_from_annotation(annotation),
|
||||||
|
"display_name": scan_argument.get("display_name") or self.format_display_name(name),
|
||||||
|
"tooltip": self.resolve_tooltip(scan_argument),
|
||||||
|
"default": default,
|
||||||
|
"expert": scan_argument.get("expert", False),
|
||||||
|
"hidden": scan_argument.get("hidden", False),
|
||||||
|
"precision": scan_argument.get("precision"),
|
||||||
|
"units": scan_argument.get("units"),
|
||||||
|
"reference_units": scan_argument.get("reference_units"),
|
||||||
|
"gt": scan_argument.get("gt"),
|
||||||
|
"ge": scan_argument.get("ge"),
|
||||||
|
"lt": scan_argument.get("lt"),
|
||||||
|
"le": scan_argument.get("le"),
|
||||||
|
"alternative_group": scan_argument.get("alternative_group"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def build_scan_ui_config(self, scan_info: ScanInfo) -> ScanUIConfig:
|
||||||
|
"""Normalize one available-scan entry into the widget UI configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scan_info (ScanInfo): Available-scan payload for one scan.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ScanUIConfig: Legacy group structure consumed by ``ScanControl`` and
|
||||||
|
``ScanGroupBox``.
|
||||||
|
"""
|
||||||
|
gui_visualization = (
|
||||||
|
scan_info.get("gui_visualization") or scan_info.get("gui_visibility") or {}
|
||||||
|
)
|
||||||
|
if not gui_visualization and scan_info.get("gui_config"):
|
||||||
|
return scan_info["gui_config"]
|
||||||
|
|
||||||
|
signature = scan_info.get("signature", [])
|
||||||
|
signature_by_name = {entry["name"]: entry for entry in signature}
|
||||||
|
|
||||||
|
arg_group = None
|
||||||
|
arg_input = scan_info.get("arg_input", {})
|
||||||
|
if isinstance(arg_input, dict) and arg_input:
|
||||||
|
bundle_size = scan_info.get("arg_bundle_size", {})
|
||||||
|
inputs = [
|
||||||
|
self.scan_input_from_arg_input(name, item_type, signature_by_name)
|
||||||
|
for name, item_type in arg_input.items()
|
||||||
|
]
|
||||||
|
arg_group = {
|
||||||
|
"name": "Scan Arguments",
|
||||||
|
"bundle": bundle_size.get("bundle"),
|
||||||
|
"arg_inputs": arg_input,
|
||||||
|
"inputs": inputs,
|
||||||
|
"min": bundle_size.get("min"),
|
||||||
|
"max": bundle_size.get("max"),
|
||||||
|
}
|
||||||
|
|
||||||
|
kwarg_groups = []
|
||||||
|
arg_names = set(arg_input) if isinstance(arg_input, dict) else set()
|
||||||
|
visible_kwarg_names = set()
|
||||||
|
for group_name, input_names in gui_visualization.items():
|
||||||
|
inputs = []
|
||||||
|
for input_name in input_names:
|
||||||
|
if input_name in arg_names or input_name not in signature_by_name:
|
||||||
|
continue
|
||||||
|
if input_name in visible_kwarg_names:
|
||||||
|
continue
|
||||||
|
param = signature_by_name[input_name]
|
||||||
|
if param.get("kind") in ("VAR_POSITIONAL", "VAR_KEYWORD"):
|
||||||
|
continue
|
||||||
|
scan_input = self.scan_input_from_signature(param)
|
||||||
|
if scan_input.get("hidden"):
|
||||||
|
continue
|
||||||
|
inputs.append(scan_input)
|
||||||
|
visible_kwarg_names.add(input_name)
|
||||||
|
if inputs:
|
||||||
|
kwarg_groups.append({"name": group_name, "inputs": inputs})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"scan_class_name": scan_info.get("class"),
|
||||||
|
"arg_group": arg_group,
|
||||||
|
"kwarg_groups": kwarg_groups,
|
||||||
|
}
|
||||||
@@ -5,10 +5,10 @@ from bec_lib.logger import bec_logger
|
|||||||
from qtpy.QtCore import Signal
|
from qtpy.QtCore import Signal
|
||||||
from qtpy.QtWidgets import QPushButton, QSizePolicy, QTreeWidgetItem, QVBoxLayout, QWidget
|
from qtpy.QtWidgets import QPushButton, QSizePolicy, QTreeWidgetItem, QVBoxLayout, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils import UILoader
|
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
from bec_widgets.utils.colors import get_accent_colors
|
from bec_widgets.utils.colors import get_accent_colors
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
|
from bec_widgets.utils.ui_loader import UILoader
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
|
|
||||||
|
|||||||
@@ -2,25 +2,20 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Literal
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
from bec_lib import bec_logger, messages
|
from bec_lib import bec_logger, messages
|
||||||
from bec_lib.endpoints import MessageEndpoints
|
from bec_lib.endpoints import MessageEndpoints
|
||||||
|
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from qtpy.QtCore import QObject, Qt, QThread, QTimer, Signal
|
from qtpy.QtCore import QObject, Qt, QThread, QTimer, Signal
|
||||||
from qtpy.QtGui import QTransform
|
from qtpy.QtGui import QTransform
|
||||||
from scipy.interpolate import (
|
|
||||||
CloughTocher2DInterpolator,
|
|
||||||
LinearNDInterpolator,
|
|
||||||
NearestNDInterpolator,
|
|
||||||
)
|
|
||||||
from scipy.spatial import cKDTree
|
|
||||||
from toolz import partition
|
from toolz import partition
|
||||||
|
|
||||||
from bec_widgets.utils import Colors
|
|
||||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||||
|
from bec_widgets.utils.colors import Colors
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||||
@@ -32,6 +27,22 @@ from bec_widgets.widgets.plots.plot_base import PlotBase
|
|||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from scipy.interpolate import (
|
||||||
|
CloughTocher2DInterpolator,
|
||||||
|
LinearNDInterpolator,
|
||||||
|
NearestNDInterpolator,
|
||||||
|
)
|
||||||
|
from scipy.spatial import cKDTree
|
||||||
|
else:
|
||||||
|
|
||||||
|
CloughTocher2DInterpolator, LinearNDInterpolator, NearestNDInterpolator = lazy_import_from(
|
||||||
|
"scipy.interpolate",
|
||||||
|
["CloughTocher2DInterpolator", "LinearNDInterpolator", "NearestNDInterpolator"],
|
||||||
|
)
|
||||||
|
cKDTree = lazy_import_from("scipy.spatial", ["cKDTree"])
|
||||||
|
|
||||||
|
|
||||||
class HeatmapDeviceSignal(BaseModel):
|
class HeatmapDeviceSignal(BaseModel):
|
||||||
"""The configuration of a signal in the scatter waveform widget."""
|
"""The configuration of a signal in the scatter waveform widget."""
|
||||||
|
|
||||||
@@ -611,7 +622,7 @@ class Heatmap(ImageBase):
|
|||||||
scan_msg = self.scan_item.status_message
|
scan_msg = self.scan_item.status_message
|
||||||
elif hasattr(self.scan_item, "metadata"):
|
elif hasattr(self.scan_item, "metadata"):
|
||||||
metadata = self.scan_item.metadata["bec"]
|
metadata = self.scan_item.metadata["bec"]
|
||||||
status = metadata["exit_status"]
|
status = metadata["status"]
|
||||||
scan_id = metadata["scan_id"]
|
scan_id = metadata["scan_id"]
|
||||||
scan_name = metadata["scan_name"]
|
scan_name = metadata["scan_name"]
|
||||||
scan_type = metadata["scan_type"]
|
scan_type = metadata["scan_type"]
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import os
|
|||||||
|
|
||||||
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout
|
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout
|
||||||
|
|
||||||
from bec_widgets.utils import UILoader
|
|
||||||
from bec_widgets.utils.error_popups import SafeSlot
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
from bec_widgets.utils.settings_dialog import SettingWidget
|
from bec_widgets.utils.settings_dialog import SettingWidget
|
||||||
|
from bec_widgets.utils.ui_loader import UILoader
|
||||||
|
|
||||||
|
|
||||||
class HeatmapSettings(SettingWidget):
|
class HeatmapSettings(SettingWidget):
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from pydantic import BaseModel, Field, field_validator
|
|||||||
from qtpy.QtCore import QTimer
|
from qtpy.QtCore import QTimer
|
||||||
from qtpy.QtWidgets import QWidget
|
from qtpy.QtWidgets import QWidget
|
||||||
|
|
||||||
from bec_widgets.utils import ConnectionConfig
|
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||||
from bec_widgets.utils.colors import Colors, apply_theme
|
from bec_widgets.utils.colors import Colors, apply_theme
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
from bec_widgets.widgets.plots.image.image_base import ImageBase
|
from bec_widgets.widgets.plots.image.image_base import ImageBase
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|||||||
from qtpy.QtCore import QPointF, Signal, SignalInstance
|
from qtpy.QtCore import QPointF, Signal, SignalInstance
|
||||||
from qtpy.QtWidgets import QDialog, QVBoxLayout
|
from qtpy.QtWidgets import QDialog, QVBoxLayout
|
||||||
|
|
||||||
from bec_widgets.utils import Colors
|
from bec_widgets.utils.colors import Colors
|
||||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
from bec_widgets.utils.side_panel import SidePanel
|
from bec_widgets.utils.side_panel import SidePanel
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ from pydantic import Field, ValidationError, field_validator
|
|||||||
from qtpy.QtCore import Signal
|
from qtpy.QtCore import Signal
|
||||||
from qtpy.QtGui import QTransform
|
from qtpy.QtGui import QTransform
|
||||||
|
|
||||||
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
|
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||||
|
from bec_widgets.utils.colors import Colors
|
||||||
from bec_widgets.widgets.plots.image.image_processor import (
|
from bec_widgets.widgets.plots.image.image_processor import (
|
||||||
ImageProcessor,
|
ImageProcessor,
|
||||||
ImageStats,
|
ImageStats,
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ from qtpy.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from bec_widgets import BECWidget
|
from bec_widgets import BECWidget
|
||||||
from bec_widgets.utils import BECDispatcher, ConnectionConfig
|
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||||
|
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||||
from bec_widgets.utils.toolbars.actions import WidgetAction
|
from bec_widgets.utils.toolbars.actions import WidgetAction
|
||||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
|
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ from qtpy.QtCore import Signal
|
|||||||
from qtpy.QtGui import QColor
|
from qtpy.QtGui import QColor
|
||||||
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
|
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils import Colors, ConnectionConfig
|
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||||
from bec_widgets.utils.colors import apply_theme
|
from bec_widgets.utils.colors import Colors, apply_theme
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import os
|
|||||||
|
|
||||||
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout, QWidget
|
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils import UILoader
|
|
||||||
from bec_widgets.utils.error_popups import SafeSlot
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
from bec_widgets.utils.settings_dialog import SettingWidget
|
from bec_widgets.utils.settings_dialog import SettingWidget
|
||||||
|
from bec_widgets.utils.ui_loader import UILoader
|
||||||
from bec_widgets.utils.widget_io import WidgetIO
|
from bec_widgets.utils.widget_io import WidgetIO
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ from qtpy.QtWidgets import QHBoxLayout, QSizePolicy, QWidget
|
|||||||
from bec_widgets.utils.toolbars.actions import NoCheckDelegate, WidgetAction
|
from bec_widgets.utils.toolbars.actions import NoCheckDelegate, WidgetAction
|
||||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
||||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
|
||||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
BECDeviceFilter,
|
||||||
|
DeviceComboBox,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MotorSelection(QWidget):
|
class MotorSelection(QWidget):
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ from pydantic import Field, ValidationError, field_validator
|
|||||||
from qtpy.QtCore import Signal
|
from qtpy.QtCore import Signal
|
||||||
from qtpy.QtWidgets import QWidget
|
from qtpy.QtWidgets import QWidget
|
||||||
|
|
||||||
from bec_widgets.utils import Colors, ConnectionConfig
|
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||||
|
from bec_widgets.utils.colors import Colors
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
from bec_widgets.utils.side_panel import SidePanel
|
from bec_widgets.utils.side_panel import SidePanel
|
||||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import os
|
|||||||
|
|
||||||
from qtpy.QtWidgets import QVBoxLayout, QWidget
|
from qtpy.QtWidgets import QVBoxLayout, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils import UILoader
|
|
||||||
from bec_widgets.utils.error_popups import SafeSlot
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
from bec_widgets.utils.settings_dialog import SettingWidget
|
from bec_widgets.utils.settings_dialog import SettingWidget
|
||||||
|
from bec_widgets.utils.ui_loader import UILoader
|
||||||
from bec_widgets.utils.widget_io import WidgetIO
|
from bec_widgets.utils.widget_io import WidgetIO
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ from bec_widgets.utils.error_popups import SafeSlot
|
|||||||
from bec_widgets.utils.toolbars.actions import DeviceComboBoxAction, WidgetAction
|
from bec_widgets.utils.toolbars.actions import DeviceComboBoxAction, WidgetAction
|
||||||
from bec_widgets.utils.toolbars.bundles import ToolbarComponents
|
from bec_widgets.utils.toolbars.bundles import ToolbarComponents
|
||||||
from bec_widgets.utils.toolbars.toolbar import ToolbarBundle
|
from bec_widgets.utils.toolbars.toolbar import ToolbarBundle
|
||||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
|
||||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
BECDeviceFilter,
|
||||||
|
DeviceComboBox,
|
||||||
|
)
|
||||||
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
|
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ from bec_lib import bec_logger
|
|||||||
from qtpy.QtCore import QPoint, QPointF, Qt, Signal
|
from qtpy.QtCore import QPoint, QPointF, Qt, Signal
|
||||||
from qtpy.QtWidgets import QHBoxLayout, QLabel, QMainWindow, QVBoxLayout, QWidget
|
from qtpy.QtWidgets import QHBoxLayout, QLabel, QMainWindow, QVBoxLayout, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils import ConnectionConfig, Crosshair, EntryValidator
|
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
|
from bec_widgets.utils.crosshair import Crosshair
|
||||||
|
from bec_widgets.utils.entry_validator import EntryValidator
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
from bec_widgets.utils.fps_counter import FPSCounter
|
from bec_widgets.utils.fps_counter import FPSCounter
|
||||||
from bec_widgets.utils.plot_indicator_items import BECArrowItem, BECTickItem
|
from bec_widgets.utils.plot_indicator_items import BECArrowItem, BECTickItem
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from qtpy import QtCore
|
|||||||
from qtpy.QtCore import QObject, Signal
|
from qtpy.QtCore import QObject, Signal
|
||||||
|
|
||||||
from bec_widgets import SafeProperty
|
from bec_widgets import SafeProperty
|
||||||
from bec_widgets.utils import BECConnector, ConnectionConfig
|
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||||
from bec_widgets.utils.colors import Colors
|
from bec_widgets.utils.colors import Colors
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ from bec_lib import bec_logger
|
|||||||
from pydantic import BaseModel, Field, ValidationError, field_validator
|
from pydantic import BaseModel, Field, ValidationError, field_validator
|
||||||
from qtpy import QtCore
|
from qtpy import QtCore
|
||||||
|
|
||||||
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
|
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||||
|
from bec_widgets.utils.colors import Colors
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
|
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ from pydantic import Field, ValidationError, field_validator
|
|||||||
from qtpy.QtCore import QTimer, Signal
|
from qtpy.QtCore import QTimer, Signal
|
||||||
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
|
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils import Colors, ConnectionConfig
|
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||||
|
from bec_widgets.utils.colors import Colors
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import os
|
|||||||
|
|
||||||
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout
|
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout
|
||||||
|
|
||||||
from bec_widgets.utils import UILoader
|
|
||||||
from bec_widgets.utils.error_popups import SafeSlot
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
from bec_widgets.utils.settings_dialog import SettingWidget
|
from bec_widgets.utils.settings_dialog import SettingWidget
|
||||||
|
from bec_widgets.utils.ui_loader import UILoader
|
||||||
|
|
||||||
|
|
||||||
class ScatterCurveSettings(SettingWidget):
|
class ScatterCurveSettings(SettingWidget):
|
||||||
|
|||||||
+9
-9
@@ -58,7 +58,7 @@
|
|||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item row="0" column="1">
|
<item row="0" column="1">
|
||||||
<widget class="DeviceLineEdit" name="device_x"/>
|
<widget class="DeviceComboBox" name="device_x"/>
|
||||||
</item>
|
</item>
|
||||||
<item row="1" column="0">
|
<item row="1" column="0">
|
||||||
<widget class="QLabel" name="label_2">
|
<widget class="QLabel" name="label_2">
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item row="0" column="1">
|
<item row="0" column="1">
|
||||||
<widget class="DeviceLineEdit" name="device_y"/>
|
<widget class="DeviceComboBox" name="device_y"/>
|
||||||
</item>
|
</item>
|
||||||
<item row="1" column="0">
|
<item row="1" column="0">
|
||||||
<widget class="QLabel" name="label_4">
|
<widget class="QLabel" name="label_4">
|
||||||
@@ -116,7 +116,7 @@
|
|||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item row="0" column="1">
|
<item row="0" column="1">
|
||||||
<widget class="DeviceLineEdit" name="device_z"/>
|
<widget class="DeviceComboBox" name="device_z"/>
|
||||||
</item>
|
</item>
|
||||||
<item row="1" column="0">
|
<item row="1" column="0">
|
||||||
<widget class="QLabel" name="label_6">
|
<widget class="QLabel" name="label_6">
|
||||||
@@ -135,9 +135,9 @@
|
|||||||
</widget>
|
</widget>
|
||||||
<customwidgets>
|
<customwidgets>
|
||||||
<customwidget>
|
<customwidget>
|
||||||
<class>DeviceLineEdit</class>
|
<class>DeviceComboBox</class>
|
||||||
<extends>QLineEdit</extends>
|
<extends>QComboBox</extends>
|
||||||
<header>device_line_edit</header>
|
<header>device_combo_box</header>
|
||||||
</customwidget>
|
</customwidget>
|
||||||
<customwidget>
|
<customwidget>
|
||||||
<class>ToggleSwitch</class>
|
<class>ToggleSwitch</class>
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
<connections>
|
<connections>
|
||||||
<connection>
|
<connection>
|
||||||
<sender>device_x</sender>
|
<sender>device_x</sender>
|
||||||
<signal>textChanged(QString)</signal>
|
<signal>currentTextChanged(QString)</signal>
|
||||||
<receiver>signal_x</receiver>
|
<receiver>signal_x</receiver>
|
||||||
<slot>clear()</slot>
|
<slot>clear()</slot>
|
||||||
<hints>
|
<hints>
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
</connection>
|
</connection>
|
||||||
<connection>
|
<connection>
|
||||||
<sender>device_y</sender>
|
<sender>device_y</sender>
|
||||||
<signal>textChanged(QString)</signal>
|
<signal>currentTextChanged(QString)</signal>
|
||||||
<receiver>signal_y</receiver>
|
<receiver>signal_y</receiver>
|
||||||
<slot>clear()</slot>
|
<slot>clear()</slot>
|
||||||
<hints>
|
<hints>
|
||||||
@@ -186,7 +186,7 @@
|
|||||||
</connection>
|
</connection>
|
||||||
<connection>
|
<connection>
|
||||||
<sender>device_z</sender>
|
<sender>device_z</sender>
|
||||||
<signal>textChanged(QString)</signal>
|
<signal>currentTextChanged(QString)</signal>
|
||||||
<receiver>signal_z</receiver>
|
<receiver>signal_z</receiver>
|
||||||
<slot>clear()</slot>
|
<slot>clear()</slot>
|
||||||
<hints>
|
<hints>
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import os
|
|||||||
|
|
||||||
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout, QWidget
|
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils import UILoader
|
|
||||||
from bec_widgets.utils.error_popups import SafeSlot
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
from bec_widgets.utils.settings_dialog import SettingWidget
|
from bec_widgets.utils.settings_dialog import SettingWidget
|
||||||
|
from bec_widgets.utils.ui_loader import UILoader
|
||||||
from bec_widgets.utils.widget_io import WidgetIO
|
from bec_widgets.utils.widget_io import WidgetIO
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ from bec_lib import bec_logger
|
|||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from qtpy import QtCore
|
from qtpy import QtCore
|
||||||
|
|
||||||
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
|
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||||
|
from bec_widgets.utils.colors import Colors
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||||
|
|||||||
@@ -50,9 +50,10 @@ from qtpy.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from bec_widgets import SafeSlot
|
from bec_widgets import SafeSlot
|
||||||
from bec_widgets.utils import ConnectionConfig, EntryValidator
|
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
from bec_widgets.utils.colors import Colors
|
from bec_widgets.utils.colors import Colors
|
||||||
|
from bec_widgets.utils.entry_validator import EntryValidator
|
||||||
from bec_widgets.utils.toolbars.actions import WidgetAction
|
from bec_widgets.utils.toolbars.actions import WidgetAction
|
||||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
|
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
|
||||||
@@ -78,7 +79,7 @@ class CurveRow(QTreeWidgetItem):
|
|||||||
|
|
||||||
Columns:
|
Columns:
|
||||||
0: Actions (delete or "Add DAP" if source=device)
|
0: Actions (delete or "Add DAP" if source=device)
|
||||||
1..2: DeviceLineEdit and QLineEdit if source=device, or "Model" label and DapComboBox if source=dap
|
1..2: DeviceComboBox and QLineEdit if source=device, or "Model" label and DapComboBox if source=dap
|
||||||
3: ColorButton
|
3: ColorButton
|
||||||
4: Style QComboBox
|
4: Style QComboBox
|
||||||
5: Pen width QSpinBox
|
5: Pen width QSpinBox
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from bec_lib.device import Positioner
|
|||||||
from bec_lib.endpoints import MessageEndpoints
|
from bec_lib.endpoints import MessageEndpoints
|
||||||
from bec_lib.lmfit_serializer import serialize_lmfit_params, serialize_param_object
|
from bec_lib.lmfit_serializer import serialize_lmfit_params, serialize_param_object
|
||||||
from bec_lib.scan_data_container import ScanDataContainer
|
from bec_lib.scan_data_container import ScanDataContainer
|
||||||
|
from bec_lib.utils.import_utils import lazy_import
|
||||||
from pydantic import Field, ValidationError, field_validator
|
from pydantic import Field, ValidationError, field_validator
|
||||||
from qtpy.QtCore import Qt, QTimer, Signal
|
from qtpy.QtCore import Qt, QTimer, Signal
|
||||||
from qtpy.QtWidgets import (
|
from qtpy.QtWidgets import (
|
||||||
@@ -25,7 +26,7 @@ from qtpy.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from bec_widgets.utils import ConnectionConfig
|
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||||
from bec_widgets.utils.bec_signal_proxy import BECSignalProxy
|
from bec_widgets.utils.bec_signal_proxy import BECSignalProxy
|
||||||
from bec_widgets.utils.colors import Colors, apply_theme
|
from bec_widgets.utils.colors import Colors, apply_theme
|
||||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||||
@@ -54,13 +55,7 @@ _DAP_PARAM = object()
|
|||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
import lmfit # type: ignore
|
import lmfit # type: ignore
|
||||||
else:
|
else:
|
||||||
try:
|
lmfit = lazy_import("lmfit")
|
||||||
import lmfit # type: ignore
|
|
||||||
except Exception as e: # pragma: no cover
|
|
||||||
logger.warning(
|
|
||||||
f"lmfit could not be imported: {e}. Custom DAP functionality will be unavailable."
|
|
||||||
)
|
|
||||||
lmfit = None
|
|
||||||
|
|
||||||
|
|
||||||
# noinspection PyDataclass
|
# noinspection PyDataclass
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ from bec_lib.logger import bec_logger
|
|||||||
from qtpy.QtCore import QPointF, QSize, Qt
|
from qtpy.QtCore import QPointF, QSize, Qt
|
||||||
from qtpy.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
|
from qtpy.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils import Colors
|
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
|
from bec_widgets.utils.colors import Colors
|
||||||
from bec_widgets.utils.error_popups import SafeProperty
|
from bec_widgets.utils.error_popups import SafeProperty
|
||||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ from qtpy.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from bec_widgets.utils import UILoader
|
|
||||||
from bec_widgets.utils.error_popups import SafeSlot
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
from bec_widgets.utils.settings_dialog import SettingWidget
|
from bec_widgets.utils.settings_dialog import SettingWidget
|
||||||
|
from bec_widgets.utils.ui_loader import UILoader
|
||||||
from bec_widgets.widgets.progress.ring_progress_bar.ring import Ring
|
from bec_widgets.widgets.progress.ring_progress_bar.ring import Ring
|
||||||
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
|
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
|
||||||
|
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ class ScanProgressBar(BECWidget, QWidget):
|
|||||||
|
|
||||||
self.connect_to_queue()
|
self.connect_to_queue()
|
||||||
self._progress_source = None
|
self._progress_source = None
|
||||||
|
self._progress_device = None
|
||||||
self.task = None
|
self.task = None
|
||||||
self.scan_number = None
|
self.scan_number = None
|
||||||
self.progress_started.connect(lambda: print("Scan progress started"))
|
self.progress_started.connect(lambda: print("Scan progress started"))
|
||||||
@@ -166,7 +167,7 @@ class ScanProgressBar(BECWidget, QWidget):
|
|||||||
"""
|
"""
|
||||||
Set the source of the progress.
|
Set the source of the progress.
|
||||||
"""
|
"""
|
||||||
if self._progress_source == source:
|
if self._progress_source == source and self._progress_device == device:
|
||||||
self.update_source_label(source, device=device)
|
self.update_source_label(source, device=device)
|
||||||
return
|
return
|
||||||
if self._progress_source is not None:
|
if self._progress_source is not None:
|
||||||
@@ -175,10 +176,11 @@ class ScanProgressBar(BECWidget, QWidget):
|
|||||||
(
|
(
|
||||||
MessageEndpoints.scan_progress()
|
MessageEndpoints.scan_progress()
|
||||||
if self._progress_source == ProgressSource.SCAN_PROGRESS
|
if self._progress_source == ProgressSource.SCAN_PROGRESS
|
||||||
else MessageEndpoints.device_progress(device=device)
|
else MessageEndpoints.device_progress(device=self._progress_device)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
self._progress_source = source
|
self._progress_source = source
|
||||||
|
self._progress_device = None if source == ProgressSource.SCAN_PROGRESS else device
|
||||||
self.bec_dispatcher.connect_slot(
|
self.bec_dispatcher.connect_slot(
|
||||||
self.on_progress_update,
|
self.on_progress_update,
|
||||||
(
|
(
|
||||||
@@ -316,11 +318,16 @@ class ScanProgressBar(BECWidget, QWidget):
|
|||||||
(
|
(
|
||||||
MessageEndpoints.scan_progress()
|
MessageEndpoints.scan_progress()
|
||||||
if self._progress_source == ProgressSource.SCAN_PROGRESS
|
if self._progress_source == ProgressSource.SCAN_PROGRESS
|
||||||
else MessageEndpoints.device_progress(device=self._progress_source.value)
|
else MessageEndpoints.device_progress(device=self._progress_device)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
self._progress_source = None
|
||||||
|
self._progress_device = None
|
||||||
self.progressbar.close()
|
self.progressbar.close()
|
||||||
self.progressbar.deleteLater()
|
self.progressbar.deleteLater()
|
||||||
|
self.bec_dispatcher.disconnect_slot(
|
||||||
|
self.on_queue_update, MessageEndpoints.scan_queue_status()
|
||||||
|
)
|
||||||
super().cleanup()
|
super().cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ from pyqtgraph import SignalProxy
|
|||||||
from qtpy.QtCore import QThreadPool, Signal
|
from qtpy.QtCore import QThreadPool, Signal
|
||||||
from qtpy.QtWidgets import QFileDialog, QListWidget, QToolButton, QVBoxLayout, QWidget
|
from qtpy.QtWidgets import QFileDialog, QListWidget, QToolButton, QVBoxLayout, QWidget
|
||||||
|
|
||||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
from bec_widgets.utils.error_popups import SafeSlot
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
from bec_widgets.utils.list_of_expandable_frames import ListOfExpandableFrames
|
from bec_widgets.utils.list_of_expandable_frames import ListOfExpandableFrames
|
||||||
|
from bec_widgets.utils.rpc_register import RPCRegister
|
||||||
from bec_widgets.utils.ui_loader import UILoader
|
from bec_widgets.utils.ui_loader import UILoader
|
||||||
from bec_widgets.widgets.services.device_browser.device_item import DeviceItem
|
from bec_widgets.widgets.services.device_browser.device_item import DeviceItem
|
||||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
|
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
"""Utilities for filtering and formatting in the LogPanel"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from collections import deque
|
|
||||||
from typing import Callable, Iterator
|
|
||||||
|
|
||||||
from bec_lib.logger import LogLevel
|
|
||||||
from bec_lib.messages import LogMessage
|
|
||||||
from qtpy.QtCore import QDateTime
|
|
||||||
|
|
||||||
LinesHtmlFormatter = Callable[[deque[LogMessage]], Iterator[str]]
|
|
||||||
LineFormatter = Callable[[LogMessage], str]
|
|
||||||
LineFilter = Callable[[LogMessage], bool] | None
|
|
||||||
|
|
||||||
ANSI_ESCAPE_REGEX = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
||||||
|
|
||||||
|
|
||||||
def replace_escapes(s: str):
|
|
||||||
s = ANSI_ESCAPE_REGEX.sub("", s)
|
|
||||||
return s.replace(" ", " ").replace("\n", "<br />").replace("\t", " ")
|
|
||||||
|
|
||||||
|
|
||||||
def level_filter(msg: LogMessage, thresh: int):
|
|
||||||
return LogLevel[msg.content["log_type"].upper()].value >= thresh
|
|
||||||
|
|
||||||
|
|
||||||
def noop_format(line: LogMessage):
|
|
||||||
_textline = line.log_msg if isinstance(line.log_msg, str) else line.log_msg["text"]
|
|
||||||
return replace_escapes(_textline.strip()) + "<br />"
|
|
||||||
|
|
||||||
|
|
||||||
def simple_color_format(line: LogMessage, colors: dict[LogLevel, str]):
|
|
||||||
color = colors.get(LogLevel[line.content["log_type"].upper()]) or colors[LogLevel.INFO]
|
|
||||||
return f'<font color="{color}">{noop_format(line)}</font>'
|
|
||||||
|
|
||||||
|
|
||||||
def create_formatter(line_format: LineFormatter, line_filter: LineFilter) -> LinesHtmlFormatter:
|
|
||||||
def _formatter(data: deque[LogMessage]):
|
|
||||||
if line_filter is not None:
|
|
||||||
return (line_format(line) for line in data if line_filter(line))
|
|
||||||
else:
|
|
||||||
return (line_format(line) for line in data)
|
|
||||||
|
|
||||||
return _formatter
|
|
||||||
|
|
||||||
|
|
||||||
def log_txt(line):
|
|
||||||
return line.log_msg if isinstance(line.log_msg, str) else line.log_msg["text"]
|
|
||||||
|
|
||||||
|
|
||||||
def log_time(line):
|
|
||||||
return QDateTime.fromMSecsSinceEpoch(int(line.log_msg["record"]["time"]["timestamp"] * 1000))
|
|
||||||
|
|
||||||
|
|
||||||
def log_svc(line):
|
|
||||||
return line.log_msg["service_name"]
|
|
||||||
@@ -30,7 +30,7 @@ class LogPanelPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
|||||||
return DOM_XML
|
return DOM_XML
|
||||||
|
|
||||||
def group(self):
|
def group(self):
|
||||||
return "BEC Services"
|
return ""
|
||||||
|
|
||||||
def icon(self):
|
def icon(self):
|
||||||
return designer_material_icon(LogPanel.ICON_NAME)
|
return designer_material_icon(LogPanel.ICON_NAME)
|
||||||
@@ -51,7 +51,7 @@ class LogPanelPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
|||||||
return "LogPanel"
|
return "LogPanel"
|
||||||
|
|
||||||
def toolTip(self):
|
def toolTip(self):
|
||||||
return "Displays a log panel"
|
return "LogPanel"
|
||||||
|
|
||||||
def whatsThis(self):
|
def whatsThis(self):
|
||||||
return self.toolTip()
|
return self.toolTip()
|
||||||
|
|||||||
@@ -2,21 +2,31 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import operator
|
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from functools import partial, reduce
|
from dataclasses import dataclass
|
||||||
from re import Pattern
|
from functools import partial
|
||||||
from typing import TYPE_CHECKING, Literal
|
from typing import Iterable, Literal
|
||||||
|
|
||||||
from bec_lib.client import BECClient
|
from bec_lib.client import BECClient
|
||||||
from bec_lib.endpoints import MessageEndpoints
|
from bec_lib.endpoints import MessageEndpoints
|
||||||
from bec_lib.logger import LogLevel, bec_logger
|
from bec_lib.logger import LogLevel, bec_logger
|
||||||
from bec_lib.messages import LogMessage, StatusMessage
|
from bec_lib.messages import LogMessage, StatusMessage
|
||||||
from pyqtgraph import SignalProxy
|
from bec_qthemes import material_icon
|
||||||
from qtpy.QtCore import QDateTime, QObject, Qt, Signal
|
from qtpy.QtCore import Signal # type: ignore
|
||||||
from qtpy.QtGui import QFont
|
from qtpy.QtCore import (
|
||||||
|
QAbstractTableModel,
|
||||||
|
QCoreApplication,
|
||||||
|
QDateTime,
|
||||||
|
QModelIndex,
|
||||||
|
QObject,
|
||||||
|
QPersistentModelIndex,
|
||||||
|
QSize,
|
||||||
|
QSortFilterProxyModel,
|
||||||
|
Qt,
|
||||||
|
QTimer,
|
||||||
|
)
|
||||||
|
from qtpy.QtGui import QColor
|
||||||
from qtpy.QtWidgets import (
|
from qtpy.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
@@ -25,204 +35,414 @@ from qtpy.QtWidgets import (
|
|||||||
QDialog,
|
QDialog,
|
||||||
QGridLayout,
|
QGridLayout,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
|
QHeaderView,
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QScrollArea,
|
QSizePolicy,
|
||||||
QTextEdit,
|
QTableView,
|
||||||
|
QToolButton,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
from thefuzz import fuzz
|
||||||
|
|
||||||
from bec_widgets.utils.bec_connector import BECConnector
|
from bec_widgets.utils.bec_connector import BECConnector
|
||||||
from bec_widgets.utils.colors import apply_theme, get_theme_palette
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
|
from bec_widgets.utils.colors import apply_theme
|
||||||
from bec_widgets.utils.error_popups import SafeSlot
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
from bec_widgets.widgets.editors.text_box.text_box import TextBox
|
|
||||||
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECServiceStatusMixin
|
|
||||||
from bec_widgets.widgets.utility.logpanel._util import (
|
|
||||||
LineFilter,
|
|
||||||
LineFormatter,
|
|
||||||
LinesHtmlFormatter,
|
|
||||||
create_formatter,
|
|
||||||
level_filter,
|
|
||||||
log_svc,
|
|
||||||
log_time,
|
|
||||||
log_txt,
|
|
||||||
noop_format,
|
|
||||||
simple_color_format,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
|
||||||
from qtpy.QtCore import SignalInstance
|
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
|
|
||||||
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
# TODO: improve log color handling
|
_DEFAULT_LOG_COLORS = {
|
||||||
DEFAULT_LOG_COLORS = {
|
LogLevel.INFO.name: QColor("#FFFFFF"),
|
||||||
LogLevel.INFO: "#FFFFFF",
|
LogLevel.SUCCESS.name: QColor("#00FF00"),
|
||||||
LogLevel.SUCCESS: "#00FF00",
|
LogLevel.WARNING.name: QColor("#FFCC00"),
|
||||||
LogLevel.WARNING: "#FFCC00",
|
LogLevel.ERROR.name: QColor("#FF0000"),
|
||||||
LogLevel.ERROR: "#FF0000",
|
LogLevel.DEBUG.name: QColor("#0000CC"),
|
||||||
LogLevel.DEBUG: "#0000CC",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Constants:
|
||||||
|
FUZZ_THRESHOLD = 80
|
||||||
|
UPDATE_INTERVAL_MS = 200
|
||||||
|
headers = ["level", "timestamp", "service_name", "message", "function"]
|
||||||
|
|
||||||
|
|
||||||
|
_CONST = _Constants()
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampUpdate:
|
||||||
|
def __init__(self, value: QDateTime | None, update_type: Literal["start", "end"]) -> None:
|
||||||
|
self.value = value
|
||||||
|
self.update_type = update_type
|
||||||
|
|
||||||
|
|
||||||
class BecLogsQueue(BECConnector, QObject):
|
class BecLogsQueue(BECConnector, QObject):
|
||||||
"""Manages getting logs from BEC Redis and formatting them for display"""
|
"""Manages getting logs from BEC Redis and formatting them for display"""
|
||||||
|
|
||||||
RPC = False
|
RPC = False
|
||||||
new_message = Signal()
|
new_messages = Signal()
|
||||||
|
paused = Signal(bool)
|
||||||
|
_instance: BecLogsQueue | None = None
|
||||||
|
|
||||||
def __init__(
|
@classmethod
|
||||||
self,
|
def instance(cls):
|
||||||
parent: QObject | None,
|
if cls._instance is None:
|
||||||
maxlen: int = 1000,
|
cls._instance = cls(QCoreApplication.instance())
|
||||||
line_formatter: LineFormatter = noop_format,
|
return cls._instance
|
||||||
**kwargs,
|
|
||||||
) -> None:
|
def __init__(self, parent: QObject | None, maxlen: int = 2500, **kwargs) -> None:
|
||||||
|
if BecLogsQueue._instance:
|
||||||
|
raise RuntimeError("Create no more than one BecLogsQueue - use BecLogsQueue.instance()")
|
||||||
super().__init__(parent=parent, **kwargs)
|
super().__init__(parent=parent, **kwargs)
|
||||||
self._timestamp_start: QDateTime | None = None
|
|
||||||
self._timestamp_end: QDateTime | None = None
|
|
||||||
self._max_length = maxlen
|
self._max_length = maxlen
|
||||||
self._data: deque[LogMessage] = deque([], self._max_length)
|
self._paused = False
|
||||||
self._display_queue: deque[str] = deque([], self._max_length)
|
self._data = deque(
|
||||||
self._log_level: str | None = None
|
(
|
||||||
self._search_query: Pattern | str | None = None
|
item["data"]
|
||||||
self._selected_services: set[str] | None = None
|
for item in self.bec_dispatcher.client.connector.xread(
|
||||||
self._set_formatter_and_update_filter(line_formatter)
|
MessageEndpoints.log(), count=self._max_length, id="0"
|
||||||
# instance attribute still accessible after c++ object is deleted, so the callback can be unregistered
|
)
|
||||||
|
),
|
||||||
|
maxlen=self._max_length,
|
||||||
|
)
|
||||||
|
self._incoming: deque[LogMessage] = deque([], maxlen=self._max_length)
|
||||||
self.bec_dispatcher.connect_slot(self._process_incoming_log_msg, MessageEndpoints.log())
|
self.bec_dispatcher.connect_slot(self._process_incoming_log_msg, MessageEndpoints.log())
|
||||||
|
|
||||||
|
self._update_timer = QTimer(self, interval=_CONST.UPDATE_INTERVAL_MS)
|
||||||
|
self._update_timer.timeout.connect(self._proc_update)
|
||||||
|
QCoreApplication.instance().aboutToQuit.connect(self.cleanup) # type: ignore
|
||||||
|
self._update_timer.start()
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self._data)
|
||||||
|
|
||||||
|
@SafeSlot()
|
||||||
|
def toggle_pause(self):
|
||||||
|
self._paused = not self._paused
|
||||||
|
self.paused.emit(self._paused)
|
||||||
|
|
||||||
|
def row_data(self, index: int) -> LogMessage | None:
|
||||||
|
if index < 0 or index > (len(self._data) - 1):
|
||||||
|
return None
|
||||||
|
return self._data[index]
|
||||||
|
|
||||||
|
def cell_data(self, row: int, key: str):
|
||||||
|
if key == "level":
|
||||||
|
return self._data[row].log_type.upper()
|
||||||
|
|
||||||
|
msg_item = self._data[row].log_msg
|
||||||
|
if isinstance(msg_item, str):
|
||||||
|
return msg_item
|
||||||
|
if key == "service_name":
|
||||||
|
return msg_item.get(key)
|
||||||
|
elif key in ["service_name", "function", "message"]:
|
||||||
|
return msg_item.get("record", {}).get(key)
|
||||||
|
elif key == "timestamp":
|
||||||
|
return msg_item.get("record", {}).get("time", {}).get("repr")
|
||||||
|
|
||||||
|
def log_timestamp(self, row: int) -> float:
|
||||||
|
msg_item = self._data[row].log_msg
|
||||||
|
if isinstance(msg_item, str):
|
||||||
|
return 0
|
||||||
|
return msg_item.get("record", {}).get("time", {}).get("timestamp")
|
||||||
|
|
||||||
def cleanup(self, *_):
|
def cleanup(self, *_):
|
||||||
"""Stop listening to the Redis log stream"""
|
"""Stop listening to the Redis log stream"""
|
||||||
self.bec_dispatcher.disconnect_slot(
|
self.bec_dispatcher.disconnect_slot(
|
||||||
self._process_incoming_log_msg, [MessageEndpoints.log()]
|
self._process_incoming_log_msg, [MessageEndpoints.log()]
|
||||||
)
|
)
|
||||||
|
self._update_timer.stop()
|
||||||
|
BecLogsQueue._instance = None
|
||||||
|
|
||||||
@SafeSlot(verify_sender=True)
|
@SafeSlot(verify_sender=True)
|
||||||
def _process_incoming_log_msg(self, msg: dict, _metadata: dict):
|
def _process_incoming_log_msg(self, msg: dict, _metadata: dict):
|
||||||
try:
|
try:
|
||||||
_msg = LogMessage(**msg)
|
_msg = LogMessage(**msg)
|
||||||
self._data.append(_msg)
|
self._incoming.append(_msg)
|
||||||
if self.filter is None or self.filter(_msg):
|
|
||||||
self._display_queue.append(self._line_formatter(_msg))
|
|
||||||
self.new_message.emit()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if "Internal C++ object (BecLogsQueue) already deleted." in e.args:
|
if "Internal C++ object (BecLogsQueue) already deleted." in e.args:
|
||||||
return
|
return
|
||||||
logger.warning(f"Error in LogPanel incoming message callback: {e}")
|
logger.warning(f"Error in LogPanel incoming message callback: {e}")
|
||||||
|
|
||||||
def _set_formatter_and_update_filter(self, line_formatter: LineFormatter = noop_format):
|
@SafeSlot(verify_sender=True)
|
||||||
self._line_formatter: LineFormatter = line_formatter
|
def _proc_update(self):
|
||||||
self._queue_formatter: LinesHtmlFormatter = create_formatter(
|
if self._paused or len(self._incoming) == 0:
|
||||||
self._line_formatter, self.filter
|
|
||||||
)
|
|
||||||
|
|
||||||
def _combine_filters(self, *args: LineFilter):
|
|
||||||
return lambda msg: reduce(operator.and_, [filt(msg) for filt in args if filt is not None])
|
|
||||||
|
|
||||||
def _create_re_filter(self) -> LineFilter:
|
|
||||||
if self._search_query is None:
|
|
||||||
return None
|
|
||||||
elif isinstance(self._search_query, str):
|
|
||||||
return lambda line: self._search_query in log_txt(line)
|
|
||||||
return lambda line: self._search_query.match(log_txt(line)) is not None
|
|
||||||
|
|
||||||
def _create_service_filter(self):
|
|
||||||
return (
|
|
||||||
lambda line: self._selected_services is None or log_svc(line) in self._selected_services
|
|
||||||
)
|
|
||||||
|
|
||||||
def _create_timestamp_filter(self) -> LineFilter:
|
|
||||||
s, e = self._timestamp_start, self._timestamp_end
|
|
||||||
if s is e is None:
|
|
||||||
return lambda msg: True
|
|
||||||
|
|
||||||
def _time_filter(msg):
|
|
||||||
msg_time = log_time(msg)
|
|
||||||
if s is None:
|
|
||||||
return msg_time <= e
|
|
||||||
if e is None:
|
|
||||||
return s <= msg_time
|
|
||||||
return s <= msg_time <= e
|
|
||||||
|
|
||||||
return _time_filter
|
|
||||||
|
|
||||||
@property
|
|
||||||
def filter(self) -> LineFilter:
|
|
||||||
"""A function which filters a log message based on all applied criteria"""
|
|
||||||
thresh = LogLevel[self._log_level].value if self._log_level is not None else 0
|
|
||||||
return self._combine_filters(
|
|
||||||
partial(level_filter, thresh=thresh),
|
|
||||||
self._create_re_filter(),
|
|
||||||
self._create_timestamp_filter(),
|
|
||||||
self._create_service_filter(),
|
|
||||||
)
|
|
||||||
|
|
||||||
def update_level_filter(self, level: str):
|
|
||||||
"""Change the log-level of the level filter"""
|
|
||||||
if level not in [l.name for l in LogLevel]:
|
|
||||||
logger.error(f"Logging level {level} unrecognized for filter!")
|
|
||||||
return
|
return
|
||||||
self._log_level = level
|
self._data.extend(self._incoming)
|
||||||
self._set_formatter_and_update_filter(self._line_formatter)
|
self._incoming.clear()
|
||||||
|
self.new_messages.emit()
|
||||||
|
|
||||||
def update_search_filter(self, search_query: Pattern | str | None = None):
|
|
||||||
"""Change the string or regex to filter against"""
|
|
||||||
self._search_query = search_query
|
|
||||||
self._set_formatter_and_update_filter(self._line_formatter)
|
|
||||||
|
|
||||||
def update_time_filter(self, start: QDateTime | None, end: QDateTime | None):
|
class BecLogsTableModel(QAbstractTableModel):
|
||||||
"""Change the start and/or end times to filter against"""
|
def __init__(self, parent: QWidget | None = None):
|
||||||
self._timestamp_start = start
|
super().__init__(parent)
|
||||||
self._timestamp_end = end
|
self.log_queue = BecLogsQueue.instance()
|
||||||
self._set_formatter_and_update_filter(self._line_formatter)
|
self.log_queue.new_messages.connect(self.handle_new_messages)
|
||||||
|
self._headers = _CONST.headers
|
||||||
|
|
||||||
def update_service_filter(self, services: set[str]):
|
def rowCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()) -> int:
|
||||||
"""Change the selected services to display"""
|
return len(self.log_queue)
|
||||||
self._selected_services = services
|
|
||||||
self._set_formatter_and_update_filter(self._line_formatter)
|
|
||||||
|
|
||||||
def update_line_formatter(self, line_formatter: LineFormatter):
|
def columnCount(self, parent: QModelIndex | QPersistentModelIndex = QModelIndex()) -> int:
|
||||||
"""Update the formatter"""
|
return len(self._headers)
|
||||||
self._set_formatter_and_update_filter(line_formatter)
|
|
||||||
|
|
||||||
def display_all(self) -> str:
|
def headerData(self, section, orientation, role=int(Qt.ItemDataRole.DisplayRole)):
|
||||||
"""Return formatted output for all log messages"""
|
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
|
||||||
return "\n".join(self._queue_formatter(self._data.copy()))
|
return self._headers[section]
|
||||||
|
return None
|
||||||
|
|
||||||
def format_new(self):
|
def get_row_data(self, index: QModelIndex) -> LogMessage | None:
|
||||||
"""Return formatted output for the display queue"""
|
"""Return the row data for the given index."""
|
||||||
res = "\n".join(self._display_queue)
|
if not index.isValid():
|
||||||
self._display_queue = deque([], self._max_length)
|
return None
|
||||||
return res
|
return self.log_queue.row_data(index.row())
|
||||||
|
|
||||||
def clear_logs(self):
|
def timestamp(self, row: int):
|
||||||
"""Clear the cache and display queue"""
|
return QDateTime.fromMSecsSinceEpoch(int(self.log_queue.log_timestamp(row) * 1000))
|
||||||
self._data = deque([])
|
|
||||||
self._display_queue = deque([])
|
|
||||||
|
|
||||||
def fetch_history(self):
|
def data(self, index, role=int(Qt.ItemDataRole.DisplayRole)):
|
||||||
"""Fetch all available messages from Redis"""
|
"""Return data for the given index and role."""
|
||||||
self._data = deque(
|
if not index.isValid():
|
||||||
item["data"]
|
return
|
||||||
for item in self.bec_dispatcher.client.connector.xread(
|
if role in [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.ToolTipRole]:
|
||||||
MessageEndpoints.log().endpoint, from_start=True, count=self._max_length
|
return self.log_queue.cell_data(index.row(), self._headers[index.column()])
|
||||||
)
|
if role in [Qt.ItemDataRole.ForegroundRole]:
|
||||||
|
return self._map_log_level_color(self.log_queue.cell_data(index.row(), "level"))
|
||||||
|
|
||||||
|
def _map_log_level_color(self, data):
|
||||||
|
return _DEFAULT_LOG_COLORS.get(data)
|
||||||
|
|
||||||
|
def handle_new_messages(self):
|
||||||
|
self.dataChanged.emit(
|
||||||
|
self.index(0, 0), self.index(self.rowCount() - 1, self.columnCount() - 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
def unique_service_names_from_history(self) -> set[str]:
|
|
||||||
"""Go through the log history to determine active service names"""
|
class LogMsgProxyModel(QSortFilterProxyModel):
|
||||||
return set(msg.log_msg["service_name"] for msg in self._data)
|
show_service_column = Signal(bool)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent=None,
|
||||||
|
service_filter: set[str] | None = None,
|
||||||
|
level_filter: LogLevel | None = None,
|
||||||
|
):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._service_filter = service_filter or set()
|
||||||
|
self._level_filter: LogLevel | None = level_filter
|
||||||
|
self._filter_text: str = ""
|
||||||
|
self._fuzzy_search: bool = False
|
||||||
|
self._time_filter_start: QDateTime | None = None
|
||||||
|
self._time_filter_end: QDateTime | None = None
|
||||||
|
|
||||||
|
def get_row_data(self, rows: Iterable[QModelIndex]) -> Iterable[LogMessage | None]:
|
||||||
|
return (self.sourceModel().get_row_data(self.mapToSource(idx)) for idx in rows)
|
||||||
|
|
||||||
|
def sourceModel(self) -> BecLogsTableModel:
|
||||||
|
return super().sourceModel() # type: ignore
|
||||||
|
|
||||||
|
@SafeSlot(int, int)
|
||||||
|
def refresh(self, *_):
|
||||||
|
self.invalidateRowsFilter()
|
||||||
|
|
||||||
|
@SafeSlot(None)
|
||||||
|
@SafeSlot(set)
|
||||||
|
def update_service_filter(self, filter: set[str]):
|
||||||
|
"""Filter to the selected services (show any service in the provided set)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filter (set[str] | None): set of services for which to show logs"""
|
||||||
|
self._service_filter = filter
|
||||||
|
self.show_service_column.emit(len(filter) != 1)
|
||||||
|
self.invalidateRowsFilter()
|
||||||
|
|
||||||
|
@SafeSlot(None)
|
||||||
|
@SafeSlot(LogLevel)
|
||||||
|
def update_level_filter(self, filter: LogLevel | None):
|
||||||
|
"""Filter to the selected log level
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filter (str | None): lowest log level to show"""
|
||||||
|
self._level_filter = filter
|
||||||
|
self.invalidateRowsFilter()
|
||||||
|
|
||||||
|
@SafeSlot(str)
|
||||||
|
def update_filter_text(self, filter: str):
|
||||||
|
"""Filter messages based on text
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filter (str | None): set of services for which to show logs"""
|
||||||
|
self._filter_text = filter
|
||||||
|
self.invalidateRowsFilter()
|
||||||
|
|
||||||
|
@SafeSlot(bool)
|
||||||
|
def update_fuzzy(self, state: bool):
|
||||||
|
"""Set text filter to fuzzy search or not
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state (bool): fuzzy search on"""
|
||||||
|
self._fuzzy_search = state
|
||||||
|
self.invalidateRowsFilter()
|
||||||
|
|
||||||
|
@SafeSlot(TimestampUpdate)
|
||||||
|
def update_timestamp(self, update: TimestampUpdate):
|
||||||
|
if update.update_type == "start":
|
||||||
|
self._time_filter_start = update.value
|
||||||
|
else:
|
||||||
|
self._time_filter_end = update.value
|
||||||
|
self.invalidateRowsFilter()
|
||||||
|
|
||||||
|
def filterAcceptsRow(self, source_row: int, source_parent) -> bool:
|
||||||
|
# No service filter, and no filter text, display everything
|
||||||
|
possible_filters = [
|
||||||
|
self._service_filter,
|
||||||
|
self._level_filter,
|
||||||
|
self._filter_text,
|
||||||
|
self._time_filter_start,
|
||||||
|
self._time_filter_end,
|
||||||
|
]
|
||||||
|
if not any(map(bool, possible_filters)):
|
||||||
|
return True
|
||||||
|
model = self.sourceModel()
|
||||||
|
# Filter out services
|
||||||
|
if self._service_filter:
|
||||||
|
col = _CONST.headers.index("service_name")
|
||||||
|
if model.data(model.index(source_row, col, source_parent)) not in self._service_filter:
|
||||||
|
return False
|
||||||
|
# Filter out levels
|
||||||
|
if self._level_filter:
|
||||||
|
col = _CONST.headers.index("level")
|
||||||
|
level: str = model.data(model.index(source_row, col, source_parent)) # type: ignore
|
||||||
|
if LogLevel[level] < self._level_filter:
|
||||||
|
return False
|
||||||
|
# Filter time
|
||||||
|
if self._time_filter_start:
|
||||||
|
if model.timestamp(source_row) < self._time_filter_start:
|
||||||
|
return False
|
||||||
|
if self._time_filter_end:
|
||||||
|
if model.timestamp(source_row) > self._time_filter_end:
|
||||||
|
return False
|
||||||
|
# Filter message text - must go last because this can return True
|
||||||
|
if self._filter_text:
|
||||||
|
col = _CONST.headers.index("message")
|
||||||
|
msg: str = model.data(model.index(source_row, col, source_parent)).lower() # type: ignore
|
||||||
|
if self._fuzzy_search:
|
||||||
|
return fuzz.partial_ratio(self._filter_text.lower(), msg) >= _CONST.FUZZ_THRESHOLD
|
||||||
|
else:
|
||||||
|
return self._filter_text.lower() in msg.lower()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class BecLogTableView(QTableView):
|
||||||
|
def __init__(self, *args, max_message_width: int = 1000, **kwargs) -> None:
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
header = QHeaderView(Qt.Orientation.Horizontal, parent=self)
|
||||||
|
header.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
|
||||||
|
header.setStretchLastSection(True)
|
||||||
|
header.setMaximumSectionSize(max_message_width)
|
||||||
|
self.setHorizontalHeader(header)
|
||||||
|
|
||||||
|
def model(self) -> LogMsgProxyModel:
|
||||||
|
return super().model() # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
class LogPanel(BECWidget, QWidget):
|
||||||
|
"""Live display of the BEC logs in a table view."""
|
||||||
|
|
||||||
|
PLUGIN = True
|
||||||
|
ICON_NAME = "browse_activity"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent: QWidget | None = None,
|
||||||
|
max_message_width: int = 1000,
|
||||||
|
show_toolbar: bool = True,
|
||||||
|
service_filter: set[str] | None = None,
|
||||||
|
level_filter: LogLevel | None = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent=parent, **kwargs)
|
||||||
|
self._setup_models(service_filter=service_filter, level_filter=level_filter)
|
||||||
|
self._layout = QVBoxLayout()
|
||||||
|
self.setLayout(self._layout)
|
||||||
|
if show_toolbar:
|
||||||
|
self._setup_toolbar(client=self.client)
|
||||||
|
self._setup_table_view(max_message_width=max_message_width)
|
||||||
|
self._update_service_filter(service_filter or set())
|
||||||
|
if show_toolbar:
|
||||||
|
self._connect_toolbar()
|
||||||
|
self._proxy.show_service_column.connect(self._show_service_column)
|
||||||
|
colors = QApplication.instance().theme.accent_colors # type: ignore
|
||||||
|
dict_colors = QApplication.instance().theme.colors # type: ignore
|
||||||
|
_DEFAULT_LOG_COLORS.update(
|
||||||
|
{
|
||||||
|
LogLevel.INFO.name: dict_colors["FG"],
|
||||||
|
LogLevel.SUCCESS.name: colors.success,
|
||||||
|
LogLevel.WARNING.name: colors.warning,
|
||||||
|
LogLevel.ERROR.name: colors.emergency,
|
||||||
|
LogLevel.DEBUG.name: dict_colors["BORDER"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self._table.scrollToBottom()
|
||||||
|
|
||||||
|
def _setup_models(self, service_filter: set[str] | None, level_filter: LogLevel | None):
|
||||||
|
self._model = BecLogsTableModel(parent=self)
|
||||||
|
self._proxy = LogMsgProxyModel(
|
||||||
|
parent=self, service_filter=service_filter, level_filter=level_filter
|
||||||
|
)
|
||||||
|
self._proxy.setSourceModel(self._model)
|
||||||
|
self._model.log_queue.new_messages.connect(self._proxy.refresh)
|
||||||
|
|
||||||
|
def _setup_table_view(self, max_message_width: int) -> None:
|
||||||
|
"""Setup the table view."""
|
||||||
|
self._table = BecLogTableView(self, max_message_width=max_message_width)
|
||||||
|
self._table.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
|
self._layout.addWidget(self._table)
|
||||||
|
self._table.setModel(self._proxy)
|
||||||
|
self._table.setHorizontalScrollMode(QTableView.ScrollMode.ScrollPerPixel)
|
||||||
|
self._table.setTextElideMode(Qt.TextElideMode.ElideRight)
|
||||||
|
self._table.resizeColumnsToContents()
|
||||||
|
|
||||||
|
def _setup_toolbar(self, client: BECClient):
|
||||||
|
self._toolbar = LogPanelToolbar(self, client)
|
||||||
|
self._layout.addWidget(self._toolbar)
|
||||||
|
|
||||||
|
def _connect_toolbar(self):
|
||||||
|
self._toolbar.services_selected.connect(self._proxy.update_service_filter)
|
||||||
|
self._toolbar.search_textbox.textChanged.connect(self._proxy.update_filter_text)
|
||||||
|
self._toolbar.level_changed.connect(self._proxy.update_level_filter)
|
||||||
|
self._toolbar.fuzzy_changed.connect(self._proxy.update_fuzzy)
|
||||||
|
self._toolbar.timestamp_update.connect(self._proxy.update_timestamp)
|
||||||
|
self._toolbar.pause_button.clicked.connect(self._model.log_queue.toggle_pause)
|
||||||
|
self._model.log_queue.paused.connect(self._toolbar._update_pause_button_icon)
|
||||||
|
|
||||||
|
def _update_service_filter(self, filter: set[str]):
|
||||||
|
self._service_filter = filter
|
||||||
|
self._proxy.update_service_filter(filter)
|
||||||
|
self._table.setColumnHidden(
|
||||||
|
_CONST.headers.index("service_name"), len(self._service_filter) == 1
|
||||||
|
)
|
||||||
|
|
||||||
|
@SafeSlot(bool)
|
||||||
|
def _show_service_column(self, show: bool):
|
||||||
|
self._table.setColumnHidden(_CONST.headers.index("service_name"), not show)
|
||||||
|
|
||||||
|
def sizeHint(self) -> QSize:
|
||||||
|
return QSize(600, 300)
|
||||||
|
|
||||||
|
|
||||||
class LogPanelToolbar(QWidget):
|
class LogPanelToolbar(QWidget):
|
||||||
|
services_selected = Signal(set)
|
||||||
|
level_changed = Signal(LogLevel)
|
||||||
|
fuzzy_changed = Signal(bool)
|
||||||
|
timestamp_update = Signal(TimestampUpdate)
|
||||||
|
|
||||||
services_selected: SignalInstance = Signal(set)
|
def __init__(self, parent: QWidget | None = None, client: BECClient | None = None) -> None:
|
||||||
|
|
||||||
def __init__(self, parent: QWidget | None = None) -> None:
|
|
||||||
"""A toolbar for the logpanel, mainly used for managing the states of filters"""
|
"""A toolbar for the logpanel, mainly used for managing the states of filters"""
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|
||||||
@@ -231,51 +451,69 @@ class LogPanelToolbar(QWidget):
|
|||||||
self._timestamp_end: QDateTime | None = None
|
self._timestamp_end: QDateTime | None = None
|
||||||
|
|
||||||
self._unique_service_names: set[str] = set()
|
self._unique_service_names: set[str] = set()
|
||||||
self._services_selected: set[str] | None = None
|
self._services_selected: set[str] = set()
|
||||||
|
|
||||||
self.layout = QHBoxLayout(self) # type: ignore
|
self._layout = QHBoxLayout(self)
|
||||||
|
|
||||||
self.service_choice_button = QPushButton("Select services", self)
|
if client is not None:
|
||||||
self.layout.addWidget(self.service_choice_button)
|
self.client = client
|
||||||
self.service_choice_button.clicked.connect(self._open_service_filter_dialog)
|
self.service_choice_button = QPushButton("Select services", self)
|
||||||
|
self._layout.addWidget(self.service_choice_button)
|
||||||
|
self.service_choice_button.clicked.connect(self._open_service_filter_dialog)
|
||||||
|
self.service_list_update(self.client.service_status)
|
||||||
|
self._services_selected = self._unique_service_names
|
||||||
|
|
||||||
self.filter_level_dropdown = self._log_level_box()
|
self.filter_level_dropdown = self._log_level_box()
|
||||||
self.layout.addWidget(self.filter_level_dropdown)
|
self._layout.addWidget(self.filter_level_dropdown)
|
||||||
|
self.filter_level_dropdown.currentTextChanged.connect(self._emit_level)
|
||||||
self.clear_button = QPushButton("Clear all", self)
|
|
||||||
self.layout.addWidget(self.clear_button)
|
|
||||||
self.fetch_button = QPushButton("Fetch history", self)
|
|
||||||
self.layout.addWidget(self.fetch_button)
|
|
||||||
|
|
||||||
self._string_search_box()
|
self._string_search_box()
|
||||||
|
|
||||||
self.timerange_button = QPushButton("Set time range", self)
|
self.timerange_button = QPushButton("Set time range", self)
|
||||||
self.layout.addWidget(self.timerange_button)
|
self._layout.addWidget(self.timerange_button)
|
||||||
|
self.timerange_button.clicked.connect(self._open_datetime_dialog)
|
||||||
|
|
||||||
@property
|
self.pause_button = QToolButton()
|
||||||
def time_start(self):
|
self.pause_button.setIcon(material_icon("pause", size=(20, 20), convert_to_pixmap=False))
|
||||||
return self._timestamp_start
|
self._PLAYING_TOOLTIP = "Pause live log updates."
|
||||||
|
self._PAUSED_TOOLTIP = "Continue live log updates."
|
||||||
|
self.pause_button.setToolTip(self._PLAYING_TOOLTIP)
|
||||||
|
self._layout.addWidget(self.pause_button)
|
||||||
|
|
||||||
@property
|
@SafeSlot(bool)
|
||||||
def time_end(self):
|
def _update_pause_button_icon(self, paused):
|
||||||
return self._timestamp_end
|
if paused:
|
||||||
|
icon = "play_arrow"
|
||||||
|
tooltip = self._PAUSED_TOOLTIP
|
||||||
|
else:
|
||||||
|
icon = "pause"
|
||||||
|
tooltip = self._PLAYING_TOOLTIP
|
||||||
|
self.pause_button.setIcon(material_icon(icon, size=(20, 20), convert_to_pixmap=False))
|
||||||
|
self.pause_button.setToolTip(tooltip)
|
||||||
|
|
||||||
def _string_search_box(self):
|
def _string_search_box(self):
|
||||||
self.layout.addWidget(QLabel("Search: "))
|
self._layout.addWidget(QLabel("Search: "))
|
||||||
self.search_textbox = QLineEdit()
|
self.search_textbox = QLineEdit()
|
||||||
self.layout.addWidget(self.search_textbox)
|
self._layout.addWidget(self.search_textbox)
|
||||||
self.layout.addWidget(QLabel("Use regex: "))
|
self._layout.addWidget(QLabel("Fuzzy: "))
|
||||||
self.regex_enabled = QCheckBox()
|
self.fuzzy = QCheckBox()
|
||||||
self.layout.addWidget(self.regex_enabled)
|
self._layout.addWidget(self.fuzzy)
|
||||||
self.update_re_button = QPushButton("Update search", self)
|
self.fuzzy.checkStateChanged.connect(self._emit_fuzzy)
|
||||||
self.layout.addWidget(self.update_re_button)
|
|
||||||
|
|
||||||
def _log_level_box(self):
|
def _log_level_box(self):
|
||||||
box = QComboBox()
|
box = QComboBox()
|
||||||
box.setToolTip("Display logs with equal or greater significance to the selected level.")
|
box.setToolTip("Display logs with equal or greater significance to the selected level.")
|
||||||
[box.addItem(l.name) for l in LogLevel]
|
[box.addItem(level.name) for level in LogLevel]
|
||||||
return box
|
return box
|
||||||
|
|
||||||
|
@SafeSlot(str)
|
||||||
|
def _emit_level(self, level: str):
|
||||||
|
self.level_changed.emit(LogLevel[level])
|
||||||
|
|
||||||
|
@SafeSlot(Qt.CheckState)
|
||||||
|
def _emit_fuzzy(self, state: Qt.CheckState):
|
||||||
|
self.fuzzy_changed.emit(state == Qt.CheckState.Checked)
|
||||||
|
|
||||||
def _current_ts(self, selection_type: Literal["start", "end"]):
|
def _current_ts(self, selection_type: Literal["start", "end"]):
|
||||||
if selection_type == "start":
|
if selection_type == "start":
|
||||||
return self._timestamp_start
|
return self._timestamp_start
|
||||||
@@ -284,6 +522,7 @@ class LogPanelToolbar(QWidget):
|
|||||||
else:
|
else:
|
||||||
raise ValueError(f"timestamps can only be for the start or end, not {selection_type}")
|
raise ValueError(f"timestamps can only be for the start or end, not {selection_type}")
|
||||||
|
|
||||||
|
@SafeSlot()
|
||||||
def _open_datetime_dialog(self):
|
def _open_datetime_dialog(self):
|
||||||
"""Open dialog window for timestamp filter selection"""
|
"""Open dialog window for timestamp filter selection"""
|
||||||
self._dt_dialog = QDialog(self)
|
self._dt_dialog = QDialog(self)
|
||||||
@@ -312,8 +551,8 @@ class LogPanelToolbar(QWidget):
|
|||||||
)
|
)
|
||||||
_layout.addWidget(date_clear_button)
|
_layout.addWidget(date_clear_button)
|
||||||
|
|
||||||
for v in [("start", label_start), ("end", label_end)]:
|
date_button_set("start", label_start)
|
||||||
date_button_set(*v)
|
date_button_set("end", label_end)
|
||||||
|
|
||||||
close_button = QPushButton("Close", parent=self._dt_dialog)
|
close_button = QPushButton("Close", parent=self._dt_dialog)
|
||||||
close_button.clicked.connect(self._dt_dialog.accept)
|
close_button.clicked.connect(self._dt_dialog.accept)
|
||||||
@@ -352,27 +591,23 @@ class LogPanelToolbar(QWidget):
|
|||||||
self._timestamp_start = dt
|
self._timestamp_start = dt
|
||||||
else:
|
else:
|
||||||
self._timestamp_end = dt
|
self._timestamp_end = dt
|
||||||
|
self.timestamp_update.emit(TimestampUpdate(value=dt, update_type=selection_type))
|
||||||
|
|
||||||
@SafeSlot(dict, set)
|
def service_list_update(self, services_info: dict[str, StatusMessage]):
|
||||||
def service_list_update(
|
|
||||||
self, services_info: dict[str, StatusMessage], services_from_history: set[str], *_, **__
|
|
||||||
):
|
|
||||||
"""Change the list of services which can be selected"""
|
"""Change the list of services which can be selected"""
|
||||||
self._unique_service_names = set([s.split("/")[0] for s in services_info.keys()])
|
self._unique_service_names = set([s.split("/")[0] for s in services_info.keys()])
|
||||||
self._unique_service_names |= services_from_history
|
|
||||||
if self._services_selected is None:
|
|
||||||
self._services_selected = self._unique_service_names
|
|
||||||
|
|
||||||
@SafeSlot()
|
@SafeSlot()
|
||||||
def _open_service_filter_dialog(self):
|
def _open_service_filter_dialog(self):
|
||||||
|
self.service_list_update(self.client.service_status)
|
||||||
if len(self._unique_service_names) == 0 or self._services_selected is None:
|
if len(self._unique_service_names) == 0 or self._services_selected is None:
|
||||||
return
|
return
|
||||||
self._svc_dialog = QDialog(self)
|
self._svc_dialog = QDialog(self)
|
||||||
self._svc_dialog.setWindowTitle(f"Select services to show logs from")
|
self._svc_dialog.setWindowTitle("Select services to show logs from")
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
self._svc_dialog.setLayout(layout)
|
self._svc_dialog.setLayout(layout)
|
||||||
|
|
||||||
service_cb_grid = QGridLayout(parent=self._svc_dialog)
|
service_cb_grid = QGridLayout()
|
||||||
layout.addLayout(service_cb_grid)
|
layout.addLayout(service_cb_grid)
|
||||||
|
|
||||||
def check_box(name: str, checked: Qt.CheckState):
|
def check_box(name: str, checked: Qt.CheckState):
|
||||||
@@ -398,146 +633,6 @@ class LogPanelToolbar(QWidget):
|
|||||||
self._svc_dialog.deleteLater()
|
self._svc_dialog.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
class LogPanel(TextBox):
|
|
||||||
"""Displays a log panel"""
|
|
||||||
|
|
||||||
ICON_NAME = "terminal"
|
|
||||||
service_list_update = Signal(dict, set)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
parent=None,
|
|
||||||
client: BECClient | None = None,
|
|
||||||
service_status: BECServiceStatusMixin | None = None,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
"""Initialize the LogPanel widget."""
|
|
||||||
super().__init__(parent=parent, client=client, config={"text": ""}, **kwargs)
|
|
||||||
self._update_colors()
|
|
||||||
self._service_status = service_status or BECServiceStatusMixin(self, client=self.client) # type: ignore
|
|
||||||
self._log_manager = BecLogsQueue(
|
|
||||||
parent=self, line_formatter=partial(simple_color_format, colors=self._colors)
|
|
||||||
)
|
|
||||||
self._proxy_update = SignalProxy(
|
|
||||||
self._log_manager.new_message, rateLimit=1, slot=self._on_append
|
|
||||||
)
|
|
||||||
|
|
||||||
self.toolbar = LogPanelToolbar(parent=self)
|
|
||||||
self.toolbar_area = QScrollArea()
|
|
||||||
self.toolbar_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
||||||
self.toolbar_area.setSizeAdjustPolicy(QScrollArea.SizeAdjustPolicy.AdjustToContents)
|
|
||||||
self.toolbar_area.setFixedHeight(int(self.toolbar.clear_button.height() * 2))
|
|
||||||
self.toolbar_area.setWidget(self.toolbar)
|
|
||||||
|
|
||||||
self.layout.addWidget(self.toolbar_area)
|
|
||||||
self.toolbar.clear_button.clicked.connect(self._on_clear)
|
|
||||||
self.toolbar.fetch_button.clicked.connect(self._on_fetch)
|
|
||||||
self.toolbar.update_re_button.clicked.connect(self._on_re_update)
|
|
||||||
self.toolbar.search_textbox.returnPressed.connect(self._on_re_update)
|
|
||||||
self.toolbar.regex_enabled.checkStateChanged.connect(self._on_re_update)
|
|
||||||
self.toolbar.filter_level_dropdown.currentTextChanged.connect(self._set_level_filter)
|
|
||||||
|
|
||||||
self.toolbar.timerange_button.clicked.connect(self._choose_datetime)
|
|
||||||
self._service_status.services_update.connect(self._update_service_list)
|
|
||||||
self.service_list_update.connect(self.toolbar.service_list_update)
|
|
||||||
self.toolbar.services_selected.connect(self._update_service_filter)
|
|
||||||
|
|
||||||
self.text_box_text_edit.setFont(QFont("monospace", 12))
|
|
||||||
self.text_box_text_edit.setHtml("")
|
|
||||||
self.text_box_text_edit.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
|
|
||||||
|
|
||||||
self._connect_to_theme_change()
|
|
||||||
|
|
||||||
@SafeSlot(set)
|
|
||||||
def _update_service_filter(self, services: set[str]):
|
|
||||||
self._log_manager.update_service_filter(services)
|
|
||||||
self._on_redraw()
|
|
||||||
|
|
||||||
@SafeSlot(dict, dict)
|
|
||||||
def _update_service_list(self, services_info: dict[str, StatusMessage], *_, **__):
|
|
||||||
self.service_list_update.emit(
|
|
||||||
services_info, self._log_manager.unique_service_names_from_history()
|
|
||||||
)
|
|
||||||
|
|
||||||
@SafeSlot()
|
|
||||||
def _choose_datetime(self):
|
|
||||||
self.toolbar._open_datetime_dialog()
|
|
||||||
self._set_time_filter()
|
|
||||||
|
|
||||||
def _connect_to_theme_change(self):
|
|
||||||
"""Connect to the theme change signal."""
|
|
||||||
qapp = QApplication.instance()
|
|
||||||
if hasattr(qapp, "theme_signal"):
|
|
||||||
qapp.theme_signal.theme_updated.connect(self._on_redraw) # type: ignore
|
|
||||||
|
|
||||||
def _update_colors(self):
|
|
||||||
self._colors = DEFAULT_LOG_COLORS.copy()
|
|
||||||
self._colors.update({LogLevel.INFO: get_theme_palette().text().color().name()})
|
|
||||||
|
|
||||||
def _cursor_to_end(self):
|
|
||||||
c = self.text_box_text_edit.textCursor()
|
|
||||||
c.movePosition(c.MoveOperation.End)
|
|
||||||
self.text_box_text_edit.setTextCursor(c)
|
|
||||||
|
|
||||||
@SafeSlot()
|
|
||||||
@SafeSlot(str)
|
|
||||||
def _on_redraw(self, *_):
|
|
||||||
self._update_colors()
|
|
||||||
self._log_manager.update_line_formatter(partial(simple_color_format, colors=self._colors))
|
|
||||||
self.set_html_text(self._log_manager.display_all())
|
|
||||||
self._cursor_to_end()
|
|
||||||
|
|
||||||
@SafeSlot(verify_sender=True)
|
|
||||||
def _on_append(self, *_):
|
|
||||||
self.text_box_text_edit.insertHtml(self._log_manager.format_new())
|
|
||||||
self._cursor_to_end()
|
|
||||||
|
|
||||||
@SafeSlot()
|
|
||||||
def _on_clear(self):
|
|
||||||
self._log_manager.clear_logs()
|
|
||||||
self.set_html_text(self._log_manager.display_all())
|
|
||||||
self._cursor_to_end()
|
|
||||||
|
|
||||||
@SafeSlot()
|
|
||||||
@SafeSlot(Qt.CheckState)
|
|
||||||
def _on_re_update(self, *_):
|
|
||||||
if self.toolbar.regex_enabled.isChecked():
|
|
||||||
try:
|
|
||||||
search_query = re.compile(self.toolbar.search_textbox.text())
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to compile search regex with error {e}")
|
|
||||||
search_query = None
|
|
||||||
logger.info(f"Setting LogPanel search regex to {search_query}")
|
|
||||||
else:
|
|
||||||
search_query = self.toolbar.search_textbox.text()
|
|
||||||
logger.info(f'Setting LogPanel search string to "{search_query}"')
|
|
||||||
self._log_manager.update_search_filter(search_query)
|
|
||||||
self.set_html_text(self._log_manager.display_all())
|
|
||||||
self._cursor_to_end()
|
|
||||||
|
|
||||||
@SafeSlot()
|
|
||||||
def _on_fetch(self):
|
|
||||||
self._log_manager.fetch_history()
|
|
||||||
self.set_html_text(self._log_manager.display_all())
|
|
||||||
self._cursor_to_end()
|
|
||||||
|
|
||||||
@SafeSlot(str)
|
|
||||||
def _set_level_filter(self, level: str):
|
|
||||||
self._log_manager.update_level_filter(level)
|
|
||||||
self._on_redraw()
|
|
||||||
|
|
||||||
@SafeSlot()
|
|
||||||
def _set_time_filter(self):
|
|
||||||
self._log_manager.update_time_filter(self.toolbar.time_start, self.toolbar.time_end)
|
|
||||||
self._on_redraw()
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
self._service_status.cleanup()
|
|
||||||
self._log_manager.cleanup()
|
|
||||||
self._log_manager.deleteLater()
|
|
||||||
super().cleanup()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -545,7 +640,15 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
apply_theme("dark")
|
apply_theme("dark")
|
||||||
widget = LogPanel()
|
panel = QWidget()
|
||||||
|
queue = BecLogsQueue(panel)
|
||||||
|
layout = QVBoxLayout(panel)
|
||||||
|
layout.addWidget(QLabel("All logs, no filters:"))
|
||||||
|
layout.addWidget(LogPanel())
|
||||||
|
layout.addWidget(QLabel("All services, level filter WARNING preapplied:"))
|
||||||
|
layout.addWidget(LogPanel(level_filter=LogLevel.WARNING))
|
||||||
|
layout.addWidget(QLabel('All services, service filter {"DeviceServer"} preapplied:'))
|
||||||
|
layout.addWidget(LogPanel(service_filter={"DeviceServer"}))
|
||||||
|
|
||||||
widget.show()
|
panel.show()
|
||||||
sys.exit(app.exec())
|
sys.exit(app.exec())
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ from bec_widgets.utils.bec_widget import BECWidget
|
|||||||
from bec_widgets.utils.colors import get_accent_colors
|
from bec_widgets.utils.colors import get_accent_colors
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||||
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
|
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||||
DeviceLineEdit,
|
|
||||||
)
|
|
||||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
|
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -58,7 +56,7 @@ class ChoiceDialog(QDialog):
|
|||||||
|
|
||||||
layout = QHBoxLayout()
|
layout = QHBoxLayout()
|
||||||
|
|
||||||
self._device_field = DeviceLineEdit(parent=parent, client=client)
|
self._device_field = DeviceComboBox(parent=parent, client=client)
|
||||||
self._signal_field = SignalComboBox(parent=parent, client=client)
|
self._signal_field = SignalComboBox(parent=parent, client=client)
|
||||||
layout.addWidget(self._device_field)
|
layout.addWidget(self._device_field)
|
||||||
layout.addWidget(self._signal_field)
|
layout.addWidget(self._signal_field)
|
||||||
@@ -73,10 +71,13 @@ class ChoiceDialog(QDialog):
|
|||||||
self._signal_field.include_config_signals = show_config
|
self._signal_field.include_config_signals = show_config
|
||||||
|
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
self._device_field.textChanged.connect(self._update_device)
|
self._device_field.currentTextChanged.connect(self._update_device)
|
||||||
if device:
|
if device:
|
||||||
self._device_field.set_device(device)
|
self._device_field.set_device(device)
|
||||||
if signal and signal in set(s[0] for s in self._signal_field.signals):
|
available_signals = {
|
||||||
|
entry[0] if isinstance(entry, tuple) else entry for entry in self._signal_field.signals
|
||||||
|
}
|
||||||
|
if signal and signal in available_signals:
|
||||||
self._signal_field.set_signal(signal)
|
self._signal_field.set_signal(signal)
|
||||||
|
|
||||||
def _display_error(self):
|
def _display_error(self):
|
||||||
@@ -97,19 +98,19 @@ class ChoiceDialog(QDialog):
|
|||||||
self._device_field.set_device(device)
|
self._device_field.set_device(device)
|
||||||
self._signal_field.set_device(device)
|
self._signal_field.set_device(device)
|
||||||
self._device_field.setStyleSheet(
|
self._device_field.setStyleSheet(
|
||||||
f"QLineEdit {{ border-style: solid; border-width: 2px; border-color: {self._accent_colors.success.name() if self._accent_colors else 'green'}}}"
|
f"QComboBox {{ border-style: solid; border-width: 2px; border-color: {self._accent_colors.success.name() if self._accent_colors else 'green'}}}"
|
||||||
)
|
)
|
||||||
self.button_box.button(QDialogButtonBox.Ok).setEnabled(True)
|
self.button_box.button(QDialogButtonBox.Ok).setEnabled(True)
|
||||||
else:
|
else:
|
||||||
self._device_field.setStyleSheet(
|
self._device_field.setStyleSheet(
|
||||||
f"QLineEdit {{ border-style: solid; border-width: 2px; border-color: {self._accent_colors.emergency.name() if self._accent_colors else 'red'}}}"
|
f"QComboBox {{ border-style: solid; border-width: 2px; border-color: {self._accent_colors.emergency.name() if self._accent_colors else 'red'}}}"
|
||||||
)
|
)
|
||||||
self.button_box.button(QDialogButtonBox.Ok).setEnabled(False)
|
self.button_box.button(QDialogButtonBox.Ok).setEnabled(False)
|
||||||
self._signal_field.clear()
|
self._signal_field.clear()
|
||||||
|
|
||||||
def accept(self):
|
def accept(self):
|
||||||
self.accepted_output.emit(
|
self.accepted_output.emit(
|
||||||
self._device_field.text(), self._signal_field.selected_signal_comp_name
|
self._device_field.currentText(), self._signal_field.selected_signal_comp_name
|
||||||
)
|
)
|
||||||
self.cleanup()
|
self.cleanup()
|
||||||
return super().accept()
|
return super().accept()
|
||||||
@@ -170,7 +171,7 @@ class SignalLabel(BECWidget, QWidget):
|
|||||||
client (BECClient, optional): The BEC client. Defaults to None.
|
client (BECClient, optional): The BEC client. Defaults to None.
|
||||||
device (str, optional): The device name. Defaults to None.
|
device (str, optional): The device name. Defaults to None.
|
||||||
signal (str, optional): The signal name. Defaults to None.
|
signal (str, optional): The signal name. Defaults to None.
|
||||||
selection_dialog_config (DeviceSignalInputBaseConfig | dict, optional): Configuration for the signal selection dialog.
|
selection_dialog_config: Configuration for the signal selection dialog.
|
||||||
show_select_button (bool, optional): Whether to show the select button. Defaults to True.
|
show_select_button (bool, optional): Whether to show the select button. Defaults to True.
|
||||||
show_default_units (bool, optional): Whether to show default units. Defaults to False.
|
show_default_units (bool, optional): Whether to show default units. Defaults to False.
|
||||||
custom_label (str, optional): Custom label for the widget. Defaults to "".
|
custom_label (str, optional): Custom label for the widget. Defaults to "".
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ from qtpy import QtCore, QtGui
|
|||||||
from qtpy.QtCore import Property, Signal, Slot
|
from qtpy.QtCore import Property, Signal, Slot
|
||||||
from qtpy.QtWidgets import QSizePolicy, QVBoxLayout, QWidget
|
from qtpy.QtWidgets import QSizePolicy, QVBoxLayout, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils import Colors
|
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
|
from bec_widgets.utils.colors import Colors
|
||||||
|
|
||||||
|
|
||||||
class RoundedColorMapButton(ColorMapButton):
|
class RoundedColorMapButton(ColorMapButton):
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from qtpy.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from bec_widgets.utils import BECConnector
|
from bec_widgets.utils.bec_connector import BECConnector
|
||||||
from bec_widgets.utils.widget_highlighter import WidgetHighlighter
|
from bec_widgets.utils.widget_highlighter import WidgetHighlighter
|
||||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||||
|
|
||||||
|
|||||||
+17
-11
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "bec_widgets"
|
name = "bec_widgets"
|
||||||
version = "3.5.1"
|
version = "3.12.0"
|
||||||
description = "BEC Widgets"
|
description = "BEC Widgets"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
classifiers = [
|
classifiers = [
|
||||||
@@ -12,19 +12,19 @@ dependencies = [
|
|||||||
"PyJWT~=2.9",
|
"PyJWT~=2.9",
|
||||||
"PySide6==6.9.0",
|
"PySide6==6.9.0",
|
||||||
"PySide6-QtAds==4.4.0",
|
"PySide6-QtAds==4.4.0",
|
||||||
"bec_ipython_client~=3.107,>=3.107.2", # needed for jupyter console
|
"bec_ipython_client~=3.107,>=3.107.2", # needed for jupyter console
|
||||||
"bec_lib~=3.107,>=3.107.2",
|
"bec_lib~=3.107,>=3.107.2",
|
||||||
"bec_qthemes~=1.0, >=1.3.4",
|
"bec_qthemes~=1.0, >=1.3.4",
|
||||||
"black>=26,<27", # needed for bw-generate-cli
|
"black>=26,<27", # needed for bw-generate-cli
|
||||||
"copier~=9.7",
|
"copier~=9.7",
|
||||||
"darkdetect~=0.8",
|
"darkdetect~=0.8",
|
||||||
"isort>=5.13, <9.0", # needed for bw-generate-cli
|
"isort>=5.13, <9.0", # needed for bw-generate-cli
|
||||||
"markdown~=3.9",
|
"markdown~=3.9",
|
||||||
"ophyd_devices~=1.29, >=1.29.1",
|
"ophyd_devices~=1.29, >=1.29.1",
|
||||||
"pydantic~=2.0",
|
"pydantic~=2.0",
|
||||||
"pylsp-bec~=1.2",
|
"pylsp-bec~=1.2",
|
||||||
"pyqtgraph==0.13.7",
|
"pyqtgraph==0.13.7",
|
||||||
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
|
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
|
||||||
"qtmonaco~=0.8, >=0.8.1",
|
"qtmonaco~=0.8, >=0.8.1",
|
||||||
"qtpy~=2.4",
|
"qtpy~=2.4",
|
||||||
"thefuzz~=0.22",
|
"thefuzz~=0.22",
|
||||||
@@ -38,8 +38,8 @@ Homepage = "https://gitlab.psi.ch/bec/bec_widgets"
|
|||||||
[project.scripts]
|
[project.scripts]
|
||||||
bec-app = "bec_widgets.applications.main_app:main"
|
bec-app = "bec_widgets.applications.main_app:main"
|
||||||
bec-designer = "bec_widgets.utils.bec_designer:main"
|
bec-designer = "bec_widgets.utils.bec_designer:main"
|
||||||
bec-gui-server = "bec_widgets.cli.server:main"
|
bec-gui-server = "bec_widgets.applications.companion_app:main"
|
||||||
bw-generate-cli = "bec_widgets.cli.generate_cli:main"
|
bw-generate-cli = "bec_widgets.utils.generate_cli:main"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -52,12 +52,18 @@ dev = [
|
|||||||
"pytest-xvfb~=3.0",
|
"pytest-xvfb~=3.0",
|
||||||
"pytest~=8.0",
|
"pytest~=8.0",
|
||||||
"pytest-cov~=6.1.1",
|
"pytest-cov~=6.1.1",
|
||||||
|
"pytest-benchmark~=5.2",
|
||||||
"watchdog~=6.0",
|
"watchdog~=6.0",
|
||||||
"pre_commit~=4.2",
|
"pre_commit~=4.2",
|
||||||
]
|
]
|
||||||
qtermwidget = [
|
qtermwidget = ["pyside6_qtermwidget"]
|
||||||
"pyside6_qtermwidget",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
@@ -68,7 +74,7 @@ line-length = 100
|
|||||||
skip-magic-trailing-comma = true
|
skip-magic-trailing-comma = true
|
||||||
|
|
||||||
[tool.coverage.report]
|
[tool.coverage.report]
|
||||||
skip_empty = true # exclude empty *files*, e.g. __init__.py, from the report
|
skip_empty = true # exclude empty *files*, e.g. __init__.py, from the report
|
||||||
exclude_lines = [
|
exclude_lines = [
|
||||||
"pragma: no cover",
|
"pragma: no cover",
|
||||||
"if TYPE_CHECKING:",
|
"if TYPE_CHECKING:",
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# BENCHMARK_TITLE: Import bec_widgets
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
python -c 'import bec_widgets; print(bec_widgets.__file__)'
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# BENCHMARK_TITLE: BEC IPython client with companion app
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
bec --post-startup-file tests/benchmarks/hyperfine/utils/exit_bec_startup.py
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user