mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-05-10 00:32:10 +02:00
Compare commits
151 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a94eaede46 | |||
| 1b9a56f4d5 | |||
| a3794a22b3 | |||
| 1534118f21 | |||
| 572797626c | |||
| 40a666aa18 | |||
| 577ca4301a | |||
| df4082b31b | |||
| aadb3e129a | |||
| 0580b539fa | |||
| b79c4862c5 | |||
| 148b41e238 | |||
| 6e398e8077 | |||
| 8d75c2af1c | |||
| 24dbb885f6 | |||
| 3b7bad85d3 | |||
| de09cc660a | |||
| 4bb8e86509 | |||
| e5b76bc855 | |||
| 99176198ee | |||
| dcfc573052 | |||
| 9290a9a23b | |||
| d48b9d224f | |||
| 43c311782d | |||
| 44f7acaeda | |||
| 0b212c3100 | |||
| d8ebae49ad | |||
| 153fb62a04 | |||
| d67227d20c | |||
| dc1072c247 | |||
| beb337201c | |||
| 75162ef8a8 | |||
| cc89252fb3 | |||
| 36fa0e649c | |||
| 8e173cb17e | |||
| 322655fc5e | |||
| 2b5b7360ae | |||
| b325d1bb4f | |||
| ee6fd5fb9e | |||
| 53fe1ac63d | |||
| 58e57169e8 | |||
| 2b27faf779 | |||
| b1a3403cd3 | |||
| b38d6dc549 | |||
| cc45fed387 | |||
| 5a594925f0 | |||
| e76dea6f69 | |||
| f4c14d66db | |||
| 4ef1344fec | |||
| 5e63814afe | |||
| 6be6dafd7d | |||
| fd1edf8177 | |||
| 8102f31956 | |||
| f9b92dacc3 | |||
| a219de11c1 | |||
| 45e9f03093 | |||
| 48e2a97ece | |||
| 953760c828 | |||
| dc3129357b | |||
| 12746ae4aa | |||
| 7e9cc20e59 | |||
| 5209f4c210 | |||
| 5f30ab5aa2 | |||
| 3926c5c947 | |||
| f71c8c882f | |||
| 04a30ea04c | |||
| cbdeae15a1 | |||
| 6aa33cacfa | |||
| 73cfe8da4c | |||
| 0467d88010 | |||
| c41ef4401d | |||
| 4f2a840c21 | |||
| 91050e88ae | |||
| 028efed5bc | |||
| 80f2ca40cb | |||
| 7c32d47f52 | |||
| bf7299c31e | |||
| f3470b409d | |||
| 3486dd4e44 | |||
| 46fe5498b5 | |||
| e94ce73950 | |||
| 3cc469a3d1 | |||
| b4e1a7927d | |||
| 84950cc651 | |||
| 24cc8c7b98 | |||
| 2132ace01b | |||
| 67650b96a2 | |||
| 6b1d2958c3 | |||
| dab1defc76 | |||
| c02f509867 | |||
| b585a608c7 | |||
| 21862e8021 | |||
| 15ac1c0182 | |||
| da23a47213 | |||
| 1bb0f1a855 | |||
| f121d09baa | |||
| dd7a5e11df | |||
| 2d4eabead0 | |||
| e607d34337 | |||
| 4a2bc9fcd9 | |||
| 2ffe269727 | |||
| de5773662a | |||
| 53b50e3420 | |||
| b16f88b217 | |||
| 063e5d064c | |||
| c354a9b249 | |||
| caa4e449e4 | |||
| afc8c4733e | |||
| a00024c66f | |||
| 5c18b291b5 | |||
| 08dde431a6 | |||
| 7daa25d7c1 | |||
| 8842eb617a | |||
| 1d0634e142 | |||
| dc6946c924 | |||
| 377bad4854 | |||
| 6cdd813734 | |||
| 3f46f7eb7e | |||
| 73f474c7e7 | |||
| 2dfae4d38f | |||
| f7061baf7b | |||
| 5865d0f97d | |||
| c204815c42 | |||
| af8f3911aa | |||
| 73afb5a472 | |||
| 5836f286de | |||
| 5567274f2d | |||
| 7983a4527a | |||
| 0f63543326 | |||
| 01755aba07 | |||
| b4987fe759 | |||
| b0cb048c81 | |||
| e8c062a48f | |||
| dfe914bb7e | |||
| b66353bf6e | |||
| ead1d38b49 | |||
| b2505c6a56 | |||
| 663c00f1a4 | |||
| 3dd688540e | |||
| 092ac915a8 | |||
| 03015a72a6 | |||
| 7dcaf8fe4c | |||
| 02db6307e4 | |||
| 3a10cac7c8 | |||
| 64fecd16dd | |||
| 76639b3e04 | |||
| a767ee8331 | |||
| 5c33f1a6d4 | |||
| af320d812b | |||
| 5bfb50fdc6 | |||
| 5393a84494 |
@@ -62,4 +62,4 @@ runs:
|
|||||||
uv pip install --system -e ./ophyd_devices
|
uv pip install --system -e ./ophyd_devices
|
||||||
uv pip install --system -e ./bec/bec_lib[dev]
|
uv pip install --system -e ./bec/bec_lib[dev]
|
||||||
uv pip install --system -e ./bec/bec_ipython_client
|
uv pip install --system -e ./bec/bec_ipython_client
|
||||||
uv pip install --system -e ./bec_widgets[dev,qtermwidget]
|
uv pip install --system -e ./bec_widgets[dev,pyside6]
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
##########################
|
|
||||||
### 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())
|
|
||||||
@@ -1,454 +0,0 @@
|
|||||||
##########################
|
|
||||||
### 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())
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
##########################
|
|
||||||
### 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())
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
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,7 +23,6 @@ concurrency:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check_pr_status:
|
check_pr_status:
|
||||||
@@ -34,15 +33,6 @@ 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 == ''
|
||||||
@@ -79,9 +69,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 == ''
|
||||||
@@ -91,4 +81,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 }}
|
||||||
@@ -55,5 +55,5 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: pytest-logs
|
name: pytest-logs
|
||||||
path: ./bec/logs/*.log
|
path: ./logs/*.log
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|||||||
@@ -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,14 +30,15 @@ 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:
|
||||||
@@ -55,4 +56,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 --ignore=tests/unit_tests/benchmarks ./tests/unit_tests
|
pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
|
||||||
|
|||||||
@@ -1,30 +1,32 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -53,7 +55,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 --ignore=tests/unit_tests/benchmarks tests/unit_tests/
|
run: pytest --random-order --cov=bec_widgets --cov-config=pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail tests/unit_tests/
|
||||||
|
|
||||||
- name: Upload test artifacts
|
- name: Upload test artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -67,4 +69,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
|
||||||
+1
-3
@@ -177,6 +177,4 @@ cython_debug/
|
|||||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
#.idea/
|
#.idea/
|
||||||
#
|
|
||||||
tombi.toml
|
|
||||||
-1110
File diff suppressed because it is too large
Load Diff
+18
-12
@@ -1,13 +1,19 @@
|
|||||||
|
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}")
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from bec_lib import bec_logger
|
from bec_lib import bec_logger
|
||||||
|
|
||||||
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
|
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
|
||||||
@@ -11,32 +9,37 @@ logger = bec_logger.logger
|
|||||||
|
|
||||||
|
|
||||||
def dock_area(
|
def dock_area(
|
||||||
object_name: str | None = None, startup_profile: str | Literal["restore", "skip"] | None = None
|
object_name: str | None = None, profile: str | None = None, start_empty: bool = False
|
||||||
) -> BECDockArea:
|
) -> BECDockArea:
|
||||||
"""
|
"""
|
||||||
Create an advanced dock area using Qt Advanced Docking System.
|
Create an advanced dock area using Qt Advanced Docking System.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
object_name(str): The name of the advanced dock area.
|
object_name(str): The name of the advanced dock area.
|
||||||
startup_profile(str | Literal["restore", "skip"] | None): Startup mode for
|
profile(str|None): Optional profile to load; if None the "general" profile is used.
|
||||||
the workspace:
|
start_empty(bool): If True, start with an empty dock area when loading specified profile.
|
||||||
- None: start empty
|
|
||||||
- "restore": restore last used profile
|
|
||||||
- "skip": do not initialize profile state
|
|
||||||
- "<name>": load specific profile
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
BECDockArea: The created advanced dock area.
|
BECDockArea: The created advanced dock area.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
The "general" profile is mandatory and will always exist. If manually deleted,
|
||||||
|
it will be automatically recreated.
|
||||||
"""
|
"""
|
||||||
|
# Default to "general" profile when called from CLI without specifying a profile
|
||||||
|
effective_profile = profile if profile is not None else "general"
|
||||||
|
|
||||||
widget = BECDockArea(
|
widget = BECDockArea(
|
||||||
object_name=object_name,
|
object_name=object_name,
|
||||||
|
restore_initial_profile=True,
|
||||||
root_widget=True,
|
root_widget=True,
|
||||||
profile_namespace="bec",
|
profile_namespace="bec",
|
||||||
startup_profile=startup_profile,
|
init_profile=effective_profile,
|
||||||
|
start_empty=start_empty,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Created advanced dock area with profile: {effective_profile}, start_empty: {start_empty}"
|
||||||
)
|
)
|
||||||
logger.info(f"Created advanced dock area with startup_profile: {startup_profile}")
|
|
||||||
return widget
|
return widget
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -43,7 +43,6 @@ if TYPE_CHECKING: # pragma: no cover
|
|||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||||
START_EMPTY_PROFILE_OPTION = "Start Empty (No Profile)"
|
|
||||||
|
|
||||||
|
|
||||||
class LaunchTile(RoundedFrame):
|
class LaunchTile(RoundedFrame):
|
||||||
@@ -147,7 +146,8 @@ class LaunchTile(RoundedFrame):
|
|||||||
|
|
||||||
# Action button
|
# Action button
|
||||||
self.action_button = QPushButton("Open")
|
self.action_button = QPushButton("Open")
|
||||||
self.action_button.setStyleSheet("""
|
self.action_button.setStyleSheet(
|
||||||
|
"""
|
||||||
QPushButton {
|
QPushButton {
|
||||||
background-color: #007AFF;
|
background-color: #007AFF;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -159,7 +159,8 @@ class LaunchTile(RoundedFrame):
|
|||||||
QPushButton:hover {
|
QPushButton:hover {
|
||||||
background-color: #005BB5;
|
background-color: #005BB5;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
self.layout.addWidget(self.action_button, alignment=Qt.AlignmentFlag.AlignCenter)
|
self.layout.addWidget(self.action_button, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||||
|
|
||||||
def _fit_label_to_width(self, label: QLabel, max_width: int, min_pt: int = 10):
|
def _fit_label_to_width(self, label: QLabel, max_width: int, min_pt: int = 10):
|
||||||
@@ -188,7 +189,6 @@ class LaunchTile(RoundedFrame):
|
|||||||
|
|
||||||
class LaunchWindow(BECMainWindow):
|
class LaunchWindow(BECMainWindow):
|
||||||
RPC = True
|
RPC = True
|
||||||
PLUGIN = False
|
|
||||||
TILE_SIZE = (250, 300)
|
TILE_SIZE = (250, 300)
|
||||||
DEFAULT_LAUNCH_SIZE = (800, 600)
|
DEFAULT_LAUNCH_SIZE = (800, 600)
|
||||||
USER_ACCESS = ["show_launcher", "hide_launcher"]
|
USER_ACCESS = ["show_launcher", "hide_launcher"]
|
||||||
@@ -353,7 +353,7 @@ class LaunchWindow(BECMainWindow):
|
|||||||
def _refresh_dock_area_profiles(self, preserve_selection: bool = True) -> None:
|
def _refresh_dock_area_profiles(self, preserve_selection: bool = True) -> None:
|
||||||
"""
|
"""
|
||||||
Refresh the dock-area profile selector, optionally preserving the selection.
|
Refresh the dock-area profile selector, optionally preserving the selection.
|
||||||
Defaults to Start Empty when no valid selection can be preserved.
|
Sets the combobox to the last used profile or "general" if no selection preserved.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
preserve_selection(bool): Whether to preserve the current selection or not.
|
preserve_selection(bool): Whether to preserve the current selection or not.
|
||||||
@@ -368,10 +368,9 @@ class LaunchWindow(BECMainWindow):
|
|||||||
)
|
)
|
||||||
|
|
||||||
profiles = list_profiles("bec")
|
profiles = list_profiles("bec")
|
||||||
selector_items = [START_EMPTY_PROFILE_OPTION, *profiles]
|
|
||||||
selector.blockSignals(True)
|
selector.blockSignals(True)
|
||||||
selector.clear()
|
selector.clear()
|
||||||
for profile in selector_items:
|
for profile in profiles:
|
||||||
selector.addItem(profile)
|
selector.addItem(profile)
|
||||||
|
|
||||||
if selected_text:
|
if selected_text:
|
||||||
@@ -380,31 +379,21 @@ class LaunchWindow(BECMainWindow):
|
|||||||
if idx >= 0:
|
if idx >= 0:
|
||||||
selector.setCurrentIndex(idx)
|
selector.setCurrentIndex(idx)
|
||||||
else:
|
else:
|
||||||
# Selection no longer exists, fall back to default startup selection.
|
# Selection no longer exists, fall back to last profile or "general"
|
||||||
self._set_selector_to_default_profile(selector, profiles)
|
self._set_selector_to_default_profile(selector, profiles)
|
||||||
else:
|
else:
|
||||||
# No selection to preserve, use default startup selection.
|
# No selection to preserve, use last profile or "general"
|
||||||
self._set_selector_to_default_profile(selector, profiles)
|
self._set_selector_to_default_profile(selector, profiles)
|
||||||
selector.blockSignals(False)
|
selector.blockSignals(False)
|
||||||
|
|
||||||
def _set_selector_to_default_profile(self, selector: QComboBox, profiles: list[str]) -> None:
|
def _set_selector_to_default_profile(self, selector: QComboBox, profiles: list[str]) -> None:
|
||||||
"""
|
"""
|
||||||
Set the selector default.
|
Set the selector to the last used profile or "general" as fallback.
|
||||||
|
|
||||||
Preference order:
|
|
||||||
1) Start Empty option (if available)
|
|
||||||
2) Last used profile
|
|
||||||
3) First available profile
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
selector(QComboBox): The combobox to set.
|
selector(QComboBox): The combobox to set.
|
||||||
profiles(list[str]): List of available profiles.
|
profiles(list[str]): List of available profiles.
|
||||||
"""
|
"""
|
||||||
start_empty_idx = selector.findText(START_EMPTY_PROFILE_OPTION, Qt.MatchFlag.MatchExactly)
|
|
||||||
if start_empty_idx >= 0:
|
|
||||||
selector.setCurrentIndex(start_empty_idx)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Try to get last used profile
|
# Try to get last used profile
|
||||||
last_profile = get_last_profile(namespace="bec")
|
last_profile = get_last_profile(namespace="bec")
|
||||||
if last_profile and last_profile in profiles:
|
if last_profile and last_profile in profiles:
|
||||||
@@ -413,6 +402,13 @@ class LaunchWindow(BECMainWindow):
|
|||||||
selector.setCurrentIndex(idx)
|
selector.setCurrentIndex(idx)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Fall back to "general" profile
|
||||||
|
if "general" in profiles:
|
||||||
|
idx = selector.findText("general", Qt.MatchFlag.MatchExactly)
|
||||||
|
if idx >= 0:
|
||||||
|
selector.setCurrentIndex(idx)
|
||||||
|
return
|
||||||
|
|
||||||
# If nothing else, select first item
|
# If nothing else, select first item
|
||||||
if selector.count() > 0:
|
if selector.count() > 0:
|
||||||
selector.setCurrentIndex(0)
|
selector.setCurrentIndex(0)
|
||||||
@@ -591,14 +587,11 @@ class LaunchWindow(BECMainWindow):
|
|||||||
"""
|
"""
|
||||||
tile = self.tiles.get("dock_area")
|
tile = self.tiles.get("dock_area")
|
||||||
if tile is None or tile.selector is None:
|
if tile is None or tile.selector is None:
|
||||||
startup_profile = None
|
profile = None
|
||||||
else:
|
else:
|
||||||
selection = tile.selector.currentText().strip()
|
selection = tile.selector.currentText().strip()
|
||||||
if selection == START_EMPTY_PROFILE_OPTION:
|
profile = selection if selection else None
|
||||||
startup_profile = None
|
return self.launch("dock_area", profile=profile)
|
||||||
else:
|
|
||||||
startup_profile = selection if selection else None
|
|
||||||
return self.launch("dock_area", startup_profile=startup_profile)
|
|
||||||
|
|
||||||
def _open_widget(self):
|
def _open_widget(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,29 +1,22 @@
|
|||||||
from bec_qthemes import material_icon
|
|
||||||
from qtpy.QtGui import QAction # type: ignore
|
|
||||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget
|
from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget
|
||||||
|
|
||||||
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
|
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
|
||||||
from bec_widgets.applications.navigation_centre.side_bar import SideBar
|
from bec_widgets.applications.navigation_centre.side_bar import SideBar
|
||||||
from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem
|
from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem
|
||||||
from bec_widgets.applications.views.admin_view.admin_view import AdminView
|
|
||||||
from bec_widgets.applications.views.developer_view.developer_view import DeveloperView
|
from bec_widgets.applications.views.developer_view.developer_view import DeveloperView
|
||||||
from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView
|
from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView
|
||||||
from bec_widgets.applications.views.dock_area_view.dock_area_view import DockAreaView
|
|
||||||
from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup
|
from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup
|
||||||
from bec_widgets.utils.colors import apply_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
from bec_widgets.utils.guided_tour import GuidedTour
|
|
||||||
from bec_widgets.utils.name_utils import sanitize_namespace
|
|
||||||
from bec_widgets.utils.screen_utils import (
|
from bec_widgets.utils.screen_utils import (
|
||||||
apply_centered_size,
|
apply_centered_size,
|
||||||
available_screen_geometry,
|
available_screen_geometry,
|
||||||
main_app_size_for_screen,
|
main_app_size_for_screen,
|
||||||
)
|
)
|
||||||
|
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
|
||||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||||
|
|
||||||
|
|
||||||
class BECMainApp(BECMainWindow):
|
class BECMainApp(BECMainWindow):
|
||||||
RPC = False
|
|
||||||
PLUGIN = False
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -55,58 +48,51 @@ class BECMainApp(BECMainWindow):
|
|||||||
|
|
||||||
self._add_views()
|
self._add_views()
|
||||||
|
|
||||||
# Initialize guided tour
|
|
||||||
self.guided_tour = GuidedTour(self)
|
|
||||||
self._setup_guided_tour()
|
|
||||||
|
|
||||||
def _add_views(self):
|
def _add_views(self):
|
||||||
self.add_section("BEC Applications", "bec_apps")
|
self.add_section("BEC Applications", "bec_apps")
|
||||||
self.dock_area = DockAreaView(self)
|
self.ads = BECDockArea(self, profile_namespace="bec", auto_profile_namespace=False)
|
||||||
|
self.ads.setObjectName("MainWorkspace")
|
||||||
self.device_manager = DeviceManagerView(self)
|
self.device_manager = DeviceManagerView(self)
|
||||||
# self.developer_view = DeveloperView(self) #TODO temporary disable until the bugs with BECShell are resolved
|
self.developer_view = DeveloperView(self)
|
||||||
self.admin_view = AdminView(self)
|
|
||||||
|
|
||||||
self.add_view(icon="widgets", title="Dock Area", widget=self.dock_area, mini_text="Docks")
|
self.add_view(
|
||||||
|
icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks"
|
||||||
|
)
|
||||||
self.add_view(
|
self.add_view(
|
||||||
icon="display_settings",
|
icon="display_settings",
|
||||||
title="Device Manager",
|
title="Device Manager",
|
||||||
|
id="device_manager",
|
||||||
widget=self.device_manager,
|
widget=self.device_manager,
|
||||||
mini_text="DM",
|
mini_text="DM",
|
||||||
)
|
)
|
||||||
# TODO temporary disable until the bugs with BECShell are resolved
|
|
||||||
# self.add_view(
|
|
||||||
# icon="code_blocks",
|
|
||||||
# title="IDE",
|
|
||||||
# widget=self.developer_view,
|
|
||||||
# mini_text="IDE",
|
|
||||||
# exclusive=True,
|
|
||||||
# )
|
|
||||||
self.add_view(
|
self.add_view(
|
||||||
icon="admin_panel_settings",
|
icon="code_blocks",
|
||||||
title="Admin View",
|
title="IDE",
|
||||||
widget=self.admin_view,
|
widget=self.developer_view,
|
||||||
mini_text="Admin",
|
id="developer_view",
|
||||||
from_top=False,
|
exclusive=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if self._show_examples:
|
if self._show_examples:
|
||||||
self.add_section("Examples", "examples")
|
self.add_section("Examples", "examples")
|
||||||
waveform_view_popup = WaveformViewPopup(
|
waveform_view_popup = WaveformViewPopup(
|
||||||
parent=self, view_id="waveform_view_popup", title="Waveform Plot"
|
parent=self, id="waveform_view_popup", title="Waveform Plot"
|
||||||
)
|
)
|
||||||
waveform_view_stack = WaveformViewInline(
|
waveform_view_stack = WaveformViewInline(
|
||||||
parent=self, view_id="waveform_view_stack", title="Waveform Plot"
|
parent=self, id="waveform_view_stack", title="Waveform Plot"
|
||||||
)
|
)
|
||||||
|
|
||||||
self.add_view(
|
self.add_view(
|
||||||
icon="show_chart",
|
icon="show_chart",
|
||||||
title="Waveform With Popup",
|
title="Waveform With Popup",
|
||||||
|
id="waveform_popup",
|
||||||
widget=waveform_view_popup,
|
widget=waveform_view_popup,
|
||||||
mini_text="Popup",
|
mini_text="Popup",
|
||||||
)
|
)
|
||||||
self.add_view(
|
self.add_view(
|
||||||
icon="show_chart",
|
icon="show_chart",
|
||||||
title="Waveform InLine Stack",
|
title="Waveform InLine Stack",
|
||||||
|
id="waveform_stack",
|
||||||
widget=waveform_view_stack,
|
widget=waveform_view_stack,
|
||||||
mini_text="Stack",
|
mini_text="Stack",
|
||||||
)
|
)
|
||||||
@@ -114,9 +100,6 @@ class BECMainApp(BECMainWindow):
|
|||||||
self.set_current("dock_area")
|
self.set_current("dock_area")
|
||||||
self.sidebar.add_dark_mode_item()
|
self.sidebar.add_dark_mode_item()
|
||||||
|
|
||||||
# Add guided tour to Help menu
|
|
||||||
self._add_guided_tour_to_menu()
|
|
||||||
|
|
||||||
# --- Public API ------------------------------------------------------
|
# --- Public API ------------------------------------------------------
|
||||||
def add_section(self, title: str, id: str, position: int | None = None):
|
def add_section(self, title: str, id: str, position: int | None = None):
|
||||||
return self.sidebar.add_section(title, id, position)
|
return self.sidebar.add_section(title, id, position)
|
||||||
@@ -132,7 +115,7 @@ class BECMainApp(BECMainWindow):
|
|||||||
*,
|
*,
|
||||||
icon: str,
|
icon: str,
|
||||||
title: str,
|
title: str,
|
||||||
view_id: str | None = None,
|
id: str,
|
||||||
widget: QWidget,
|
widget: QWidget,
|
||||||
mini_text: str | None = None,
|
mini_text: str | None = None,
|
||||||
position: int | None = None,
|
position: int | None = None,
|
||||||
@@ -146,8 +129,7 @@ class BECMainApp(BECMainWindow):
|
|||||||
Args:
|
Args:
|
||||||
icon(str): Icon name for the nav item.
|
icon(str): Icon name for the nav item.
|
||||||
title(str): Title for the nav item.
|
title(str): Title for the nav item.
|
||||||
view_id(str, optional): Unique ID for the view/item. If omitted, uses mini_text;
|
id(str): Unique ID for the view/item.
|
||||||
if mini_text is also omitted, uses title.
|
|
||||||
widget(QWidget): The widget to add to the stack.
|
widget(QWidget): The widget to add to the stack.
|
||||||
mini_text(str, optional): Short text for the nav item when sidebar is collapsed.
|
mini_text(str, optional): Short text for the nav item when sidebar is collapsed.
|
||||||
position(int, optional): Position to insert the nav item.
|
position(int, optional): Position to insert the nav item.
|
||||||
@@ -160,11 +142,10 @@ class BECMainApp(BECMainWindow):
|
|||||||
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
resolved_id = sanitize_namespace(view_id or mini_text or title)
|
|
||||||
item = self.sidebar.add_item(
|
item = self.sidebar.add_item(
|
||||||
icon=icon,
|
icon=icon,
|
||||||
title=title,
|
title=title,
|
||||||
id=resolved_id,
|
id=id,
|
||||||
mini_text=mini_text,
|
mini_text=mini_text,
|
||||||
position=position,
|
position=position,
|
||||||
from_top=from_top,
|
from_top=from_top,
|
||||||
@@ -174,15 +155,13 @@ class BECMainApp(BECMainWindow):
|
|||||||
# Wrap plain widgets into a ViewBase so enter/exit hooks are available
|
# Wrap plain widgets into a ViewBase so enter/exit hooks are available
|
||||||
if isinstance(widget, ViewBase):
|
if isinstance(widget, ViewBase):
|
||||||
view_widget = widget
|
view_widget = widget
|
||||||
view_widget.view_id = resolved_id
|
view_widget.view_id = id
|
||||||
view_widget.view_title = title
|
view_widget.view_title = title
|
||||||
else:
|
else:
|
||||||
view_widget = ViewBase(content=widget, parent=self, view_id=resolved_id, title=title)
|
view_widget = ViewBase(content=widget, parent=self, id=id, title=title)
|
||||||
|
|
||||||
view_widget.change_object_name(resolved_id)
|
|
||||||
|
|
||||||
idx = self.stack.addWidget(view_widget)
|
idx = self.stack.addWidget(view_widget)
|
||||||
self._view_index[resolved_id] = idx
|
self._view_index[id] = idx
|
||||||
return item
|
return item
|
||||||
|
|
||||||
def set_current(self, id: str) -> None:
|
def set_current(self, id: str) -> None:
|
||||||
@@ -191,12 +170,6 @@ class BECMainApp(BECMainWindow):
|
|||||||
|
|
||||||
# Internal: route sidebar selection to the stack
|
# Internal: route sidebar selection to the stack
|
||||||
def _on_view_selected(self, vid: str) -> None:
|
def _on_view_selected(self, vid: str) -> None:
|
||||||
# Special handling for views that can not be switched to (e.g. dark mode toggle)
|
|
||||||
# Not registered as proper view with a stack index, so we ignore any logic below
|
|
||||||
# as it will anyways not result in a stack switch.
|
|
||||||
idx = self._view_index.get(vid)
|
|
||||||
if idx is None or not (0 <= idx < self.stack.count()):
|
|
||||||
return
|
|
||||||
# Determine current view
|
# Determine current view
|
||||||
current_index = self.stack.currentIndex()
|
current_index = self.stack.currentIndex()
|
||||||
current_view = (
|
current_view = (
|
||||||
@@ -222,160 +195,6 @@ class BECMainApp(BECMainWindow):
|
|||||||
if hasattr(new_view, "on_enter"):
|
if hasattr(new_view, "on_enter"):
|
||||||
new_view.on_enter()
|
new_view.on_enter()
|
||||||
|
|
||||||
def _setup_guided_tour(self):
|
|
||||||
"""
|
|
||||||
Setup the guided tour for the main application.
|
|
||||||
Registers key UI components and delegates to views for their internal components.
|
|
||||||
"""
|
|
||||||
tour_steps = []
|
|
||||||
|
|
||||||
# --- General Layout Components ---
|
|
||||||
|
|
||||||
# Register the sidebar toggle button
|
|
||||||
toggle_step = self.guided_tour.register_widget(
|
|
||||||
widget=self.sidebar.toggle,
|
|
||||||
title="Sidebar Toggle",
|
|
||||||
text="Click this button to expand or collapse the sidebar. When expanded, you can see full navigation item titles and section names.",
|
|
||||||
)
|
|
||||||
tour_steps.append(toggle_step)
|
|
||||||
|
|
||||||
# Register the sidebar icons
|
|
||||||
sidebar_dock_area = self.sidebar.components.get("dock_area")
|
|
||||||
if sidebar_dock_area:
|
|
||||||
dock_step = self.guided_tour.register_widget(
|
|
||||||
widget=sidebar_dock_area,
|
|
||||||
title="Dock Area View",
|
|
||||||
text="Click here to access the Dock Area view, where you can manage and arrange your dockable panels.",
|
|
||||||
)
|
|
||||||
tour_steps.append(dock_step)
|
|
||||||
|
|
||||||
sidebar_device_manager = self.sidebar.components.get("device_manager")
|
|
||||||
if sidebar_device_manager:
|
|
||||||
device_manager_step = self.guided_tour.register_widget(
|
|
||||||
widget=sidebar_device_manager,
|
|
||||||
title="Device Manager View",
|
|
||||||
text="Click here to open the Device Manager view, where you can view and manage device configs.",
|
|
||||||
)
|
|
||||||
tour_steps.append(device_manager_step)
|
|
||||||
|
|
||||||
sidebar_developer_view = self.sidebar.components.get("developer_view")
|
|
||||||
if sidebar_developer_view:
|
|
||||||
developer_view_step = self.guided_tour.register_widget(
|
|
||||||
widget=sidebar_developer_view,
|
|
||||||
title="Developer View",
|
|
||||||
text="Click here to access the Developer view to write scripts and macros.",
|
|
||||||
)
|
|
||||||
tour_steps.append(developer_view_step)
|
|
||||||
|
|
||||||
# Register the dark mode toggle
|
|
||||||
dark_mode_item = self.sidebar.components.get("dark_mode")
|
|
||||||
if dark_mode_item:
|
|
||||||
dark_mode_step = self.guided_tour.register_widget(
|
|
||||||
widget=dark_mode_item,
|
|
||||||
title="Theme Toggle",
|
|
||||||
text="Switch between light and dark themes. The theme preference is saved and will be applied when you restart the application.",
|
|
||||||
)
|
|
||||||
tour_steps.append(dark_mode_step)
|
|
||||||
|
|
||||||
# Register the client info label
|
|
||||||
if hasattr(self, "_client_info_hover"):
|
|
||||||
client_info_step = self.guided_tour.register_widget(
|
|
||||||
widget=self._client_info_hover,
|
|
||||||
title="Client Status",
|
|
||||||
text="Displays status messages and information from the BEC Server.",
|
|
||||||
)
|
|
||||||
tour_steps.append(client_info_step)
|
|
||||||
|
|
||||||
# Register the scan progress bar if available
|
|
||||||
if hasattr(self, "_scan_progress_hover"):
|
|
||||||
progress_step = self.guided_tour.register_widget(
|
|
||||||
widget=self._scan_progress_hover,
|
|
||||||
title="Scan Progress",
|
|
||||||
text="Monitor the progress of ongoing scans. Hover over the progress bar to see detailed information including elapsed time and estimated completion.",
|
|
||||||
)
|
|
||||||
tour_steps.append(progress_step)
|
|
||||||
|
|
||||||
# Register the notification indicator in the status bar
|
|
||||||
if hasattr(self, "notification_indicator"):
|
|
||||||
notif_step = self.guided_tour.register_widget(
|
|
||||||
widget=self.notification_indicator,
|
|
||||||
title="Notification Center",
|
|
||||||
text="View system notifications, errors, and status updates. Click to filter notifications by type or expand to see all details.",
|
|
||||||
)
|
|
||||||
tour_steps.append(notif_step)
|
|
||||||
|
|
||||||
# --- View-Specific Components ---
|
|
||||||
|
|
||||||
# Register all views that can extend the tour
|
|
||||||
for view_id, view_index in self._view_index.items():
|
|
||||||
view_widget = self.stack.widget(view_index)
|
|
||||||
if not view_widget or not hasattr(view_widget, "register_tour_steps"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get the view's tour steps
|
|
||||||
view_tour = view_widget.register_tour_steps(self.guided_tour, self)
|
|
||||||
if view_tour is None:
|
|
||||||
if hasattr(view_widget.content, "register_tour_steps"):
|
|
||||||
view_tour = view_widget.content.register_tour_steps(self.guided_tour, self)
|
|
||||||
if view_tour is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get the corresponding sidebar navigation item
|
|
||||||
nav_item = self.sidebar.components.get(view_id)
|
|
||||||
if not nav_item:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Use the view's title for the navigation button
|
|
||||||
nav_step = self.guided_tour.register_widget(
|
|
||||||
widget=nav_item,
|
|
||||||
title=view_tour.view_title,
|
|
||||||
text=f"Let's explore the features of the {view_tour.view_title}.",
|
|
||||||
)
|
|
||||||
tour_steps.append(nav_step)
|
|
||||||
tour_steps.extend(view_tour.step_ids)
|
|
||||||
|
|
||||||
# Create the tour with all registered steps
|
|
||||||
if tour_steps:
|
|
||||||
self.guided_tour.create_tour(tour_steps)
|
|
||||||
|
|
||||||
def start_guided_tour(self):
|
|
||||||
"""
|
|
||||||
Public method to start the guided tour.
|
|
||||||
This can be called programmatically or connected to a menu/button action.
|
|
||||||
"""
|
|
||||||
self.guided_tour.start_tour()
|
|
||||||
|
|
||||||
def _add_guided_tour_to_menu(self):
|
|
||||||
"""
|
|
||||||
Add a 'Guided Tour' action to the Help menu.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Find the Help menu
|
|
||||||
menu_bar = self.menuBar()
|
|
||||||
help_menu = None
|
|
||||||
for action in menu_bar.actions():
|
|
||||||
if action.text() == "Help":
|
|
||||||
help_menu = action.menu()
|
|
||||||
break
|
|
||||||
|
|
||||||
if help_menu:
|
|
||||||
# Add separator before the tour action
|
|
||||||
help_menu.addSeparator()
|
|
||||||
|
|
||||||
# Create and add the guided tour action
|
|
||||||
tour_action = QAction("Start Guided Tour", self)
|
|
||||||
tour_action.setIcon(material_icon("help"))
|
|
||||||
tour_action.triggered.connect(self.start_guided_tour)
|
|
||||||
tour_action.setShortcut("F1") # Add keyboard shortcut
|
|
||||||
help_menu.addAction(tour_action)
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
for view_id, idx in self._view_index.items():
|
|
||||||
view = self.stack.widget(idx)
|
|
||||||
view.close()
|
|
||||||
view.deleteLater()
|
|
||||||
super().cleanup()
|
|
||||||
|
|
||||||
|
|
||||||
def main(): # pragma: no cover
|
def main(): # pragma: no cover
|
||||||
"""
|
"""
|
||||||
@@ -394,7 +213,6 @@ def main(): # pragma: no cover
|
|||||||
args, qt_args = parser.parse_known_args(sys.argv[1:])
|
args, qt_args = parser.parse_known_args(sys.argv[1:])
|
||||||
|
|
||||||
app = QApplication([sys.argv[0], *qt_args])
|
app = QApplication([sys.argv[0], *qt_args])
|
||||||
app.setApplicationName("BEC")
|
|
||||||
apply_theme("dark")
|
apply_theme("dark")
|
||||||
w = BECMainApp(show_examples=args.examples)
|
w = BECMainApp(show_examples=args.examples)
|
||||||
|
|
||||||
|
|||||||
@@ -127,10 +127,12 @@ class NavigationItem(QWidget):
|
|||||||
self._icon_size_expanded = QtCore.QSize(26, 26)
|
self._icon_size_expanded = QtCore.QSize(26, 26)
|
||||||
self.icon_btn.setIconSize(self._icon_size_collapsed)
|
self.icon_btn.setIconSize(self._icon_size_collapsed)
|
||||||
# Remove QToolButton hover/pressed background/outline
|
# Remove QToolButton hover/pressed background/outline
|
||||||
self.icon_btn.setStyleSheet("""
|
self.icon_btn.setStyleSheet(
|
||||||
|
"""
|
||||||
QToolButton:hover { background: transparent; border: none; }
|
QToolButton:hover { background: transparent; border: none; }
|
||||||
QToolButton:pressed { background: transparent; border: none; }
|
QToolButton:pressed { background: transparent; border: none; }
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
# Mini label below icon
|
# Mini label below icon
|
||||||
self.mini_lbl = QLabel(self._mini_text, self)
|
self.mini_lbl = QLabel(self._mini_text, self)
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
"""Module for Admin View."""
|
|
||||||
|
|
||||||
from qtpy.QtWidgets import QWidget
|
|
||||||
|
|
||||||
from bec_widgets.applications.views.view import ViewBase
|
|
||||||
from bec_widgets.utils.error_popups import SafeSlot
|
|
||||||
from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_admin_view import BECAtlasAdminView
|
|
||||||
|
|
||||||
|
|
||||||
class AdminView(ViewBase):
|
|
||||||
"""
|
|
||||||
A view for administrators to change the current active experiment, manage messaging
|
|
||||||
services, and more tasks reserved for users with admin privileges.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
parent: QWidget | None = None,
|
|
||||||
content: QWidget | None = None,
|
|
||||||
*,
|
|
||||||
view_id: str | None = None,
|
|
||||||
title: str | None = None,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
super().__init__(parent=parent, content=content, view_id=view_id, title=title)
|
|
||||||
self.admin_widget = BECAtlasAdminView(parent=self)
|
|
||||||
self.set_content(self.admin_widget)
|
|
||||||
|
|
||||||
@SafeSlot()
|
|
||||||
def on_exit(self) -> None:
|
|
||||||
"""Called before the view is hidden.
|
|
||||||
|
|
||||||
Default implementation does nothing. Override in subclasses.
|
|
||||||
"""
|
|
||||||
self.admin_widget.logout()
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from qtpy.QtWidgets import QWidget
|
from qtpy.QtWidgets import QWidget
|
||||||
|
|
||||||
from bec_widgets.applications.views.developer_view.developer_widget import DeveloperWidget
|
from bec_widgets.applications.views.developer_view.developer_widget import DeveloperWidget
|
||||||
from bec_widgets.applications.views.view import ViewBase, ViewTourSteps
|
from bec_widgets.applications.views.view import ViewBase
|
||||||
|
|
||||||
|
|
||||||
class DeveloperView(ViewBase):
|
class DeveloperView(ViewBase):
|
||||||
@@ -14,89 +14,13 @@ class DeveloperView(ViewBase):
|
|||||||
parent: QWidget | None = None,
|
parent: QWidget | None = None,
|
||||||
content: QWidget | None = None,
|
content: QWidget | None = None,
|
||||||
*,
|
*,
|
||||||
view_id: str | None = None,
|
id: str | None = None,
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
**kwargs,
|
|
||||||
):
|
):
|
||||||
super().__init__(parent=parent, content=content, view_id=view_id, title=title, **kwargs)
|
super().__init__(parent=parent, content=content, id=id, title=title)
|
||||||
self.developer_widget = DeveloperWidget(parent=self)
|
self.developer_widget = DeveloperWidget(parent=self)
|
||||||
self.set_content(self.developer_widget)
|
self.set_content(self.developer_widget)
|
||||||
|
|
||||||
def register_tour_steps(self, guided_tour, main_app) -> ViewTourSteps | None:
|
|
||||||
"""Register Developer View components with the guided tour.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
guided_tour: The GuidedTour instance to register with.
|
|
||||||
main_app: The main application instance (for accessing set_current).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ViewTourSteps | None: Model containing view title and step IDs.
|
|
||||||
"""
|
|
||||||
step_ids = []
|
|
||||||
dev_widget = self.developer_widget
|
|
||||||
|
|
||||||
# IDE Toolbar
|
|
||||||
def get_ide_toolbar():
|
|
||||||
main_app.set_current("developer_view")
|
|
||||||
return (dev_widget.toolbar, None)
|
|
||||||
|
|
||||||
step_id = guided_tour.register_widget(
|
|
||||||
widget=get_ide_toolbar,
|
|
||||||
title="IDE Toolbar",
|
|
||||||
text="Quick access to save files, execute scripts, and configure IDE settings. Use the toolbar to manage your code and execution.",
|
|
||||||
)
|
|
||||||
step_ids.append(step_id)
|
|
||||||
|
|
||||||
# IDE Explorer
|
|
||||||
def get_ide_explorer():
|
|
||||||
main_app.set_current("developer_view")
|
|
||||||
return (dev_widget.explorer_dock.widget(), None)
|
|
||||||
|
|
||||||
step_id = guided_tour.register_widget(
|
|
||||||
widget=get_ide_explorer,
|
|
||||||
title="File Explorer",
|
|
||||||
text="Browse and manage your macro files. Create new files, open existing ones, and organize your scripts.",
|
|
||||||
)
|
|
||||||
step_ids.append(step_id)
|
|
||||||
|
|
||||||
# IDE Editor
|
|
||||||
def get_ide_editor():
|
|
||||||
main_app.set_current("developer_view")
|
|
||||||
return (dev_widget.monaco_dock.widget(), None)
|
|
||||||
|
|
||||||
step_id = guided_tour.register_widget(
|
|
||||||
widget=get_ide_editor,
|
|
||||||
title="Code Editor",
|
|
||||||
text="Write and edit Python code with syntax highlighting, auto-completion, and signature help. Monaco editor provides a modern coding experience.",
|
|
||||||
)
|
|
||||||
step_ids.append(step_id)
|
|
||||||
|
|
||||||
# IDE Console
|
|
||||||
def get_ide_console():
|
|
||||||
main_app.set_current("developer_view")
|
|
||||||
return (dev_widget.console_dock.widget(), None)
|
|
||||||
|
|
||||||
step_id = guided_tour.register_widget(
|
|
||||||
widget=get_ide_console,
|
|
||||||
title="BEC Shell Console",
|
|
||||||
text="Interactive Python console with BEC integration. Execute commands, test code snippets, and interact with the BEC system in real-time.",
|
|
||||||
)
|
|
||||||
step_ids.append(step_id)
|
|
||||||
|
|
||||||
# IDE Plotting Area
|
|
||||||
def get_ide_plotting():
|
|
||||||
main_app.set_current("developer_view")
|
|
||||||
return (dev_widget.plotting_ads, None)
|
|
||||||
|
|
||||||
step_id = guided_tour.register_widget(
|
|
||||||
widget=get_ide_plotting,
|
|
||||||
title="Plotting Area",
|
|
||||||
text="View plots and visualizations generated by your scripts. Arrange multiple plots in a flexible layout.",
|
|
||||||
)
|
|
||||||
step_ids.append(step_id)
|
|
||||||
|
|
||||||
return ViewTourSteps(view_title="Developer View", step_ids=step_ids)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import sys
|
import sys
|
||||||
@@ -126,11 +50,7 @@ if __name__ == "__main__":
|
|||||||
_app.resize(width, height)
|
_app.resize(width, height)
|
||||||
developer_view = DeveloperView()
|
developer_view = DeveloperView()
|
||||||
_app.add_view(
|
_app.add_view(
|
||||||
icon="code_blocks",
|
icon="code_blocks", title="IDE", widget=developer_view, id="developer_view", exclusive=True
|
||||||
title="IDE",
|
|
||||||
widget=developer_view,
|
|
||||||
view_id="developer_view",
|
|
||||||
exclusive=True,
|
|
||||||
)
|
)
|
||||||
_app.show()
|
_app.show()
|
||||||
# developer_view.show()
|
# developer_view.show()
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
|||||||
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.dock_area import BECDockArea
|
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
|
||||||
from bec_widgets.widgets.containers.qt_ads import CDockWidget
|
from bec_widgets.widgets.containers.qt_ads import CDockWidget
|
||||||
from bec_widgets.widgets.editors.bec_console.bec_console import BecConsole, BECShell
|
|
||||||
from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock
|
from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock
|
||||||
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
|
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
|
||||||
|
from bec_widgets.widgets.editors.web_console.web_console import BECShell, WebConsole
|
||||||
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
|
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
|
||||||
|
|
||||||
|
|
||||||
@@ -79,8 +79,6 @@ def markdown_to_html(md_text: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
class DeveloperWidget(DockAreaWidget):
|
class DeveloperWidget(DockAreaWidget):
|
||||||
RPC = False
|
|
||||||
PLUGIN = False
|
|
||||||
|
|
||||||
def __init__(self, parent=None, **kwargs):
|
def __init__(self, parent=None, **kwargs):
|
||||||
super().__init__(parent=parent, variant="compact", **kwargs)
|
super().__init__(parent=parent, variant="compact", **kwargs)
|
||||||
@@ -94,11 +92,11 @@ class DeveloperWidget(DockAreaWidget):
|
|||||||
self.explorer = IDEExplorer(self)
|
self.explorer = IDEExplorer(self)
|
||||||
self.explorer.setObjectName("Explorer")
|
self.explorer.setObjectName("Explorer")
|
||||||
|
|
||||||
self.console = BECShell(self, rpc_exposed=False)
|
self.console = BECShell(self)
|
||||||
self.console.setObjectName("BEC Shell")
|
self.console.setObjectName("BEC Shell")
|
||||||
self.terminal = BecConsole(self, rpc_exposed=False)
|
self.terminal = WebConsole(self)
|
||||||
self.terminal.setObjectName("Terminal")
|
self.terminal.setObjectName("Terminal")
|
||||||
self.monaco = MonacoDock(self, rpc_exposed=False, rpc_passthrough_children=False)
|
self.monaco = MonacoDock(self)
|
||||||
self.monaco.setObjectName("MonacoEditor")
|
self.monaco.setObjectName("MonacoEditor")
|
||||||
self.monaco.save_enabled.connect(self._on_save_enabled_update)
|
self.monaco.save_enabled.connect(self._on_save_enabled_update)
|
||||||
self.plotting_ads = BECDockArea(
|
self.plotting_ads = BECDockArea(
|
||||||
@@ -410,3 +408,23 @@ class DeveloperWidget(DockAreaWidget):
|
|||||||
"""Clean up resources used by the developer widget."""
|
"""Clean up resources used by the developer widget."""
|
||||||
self.delete_all()
|
self.delete_all()
|
||||||
return super().cleanup()
|
return super().cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from bec_qthemes import apply_theme
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from bec_widgets.applications.main_app import BECMainApp
|
||||||
|
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
apply_theme("dark")
|
||||||
|
|
||||||
|
_app = BECMainApp()
|
||||||
|
_app.show()
|
||||||
|
# developer_view.show()
|
||||||
|
# developer_view.setWindowTitle("Developer View")
|
||||||
|
# developer_view.resize(1920, 1080)
|
||||||
|
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
|
||||||
|
sys.exit(app.exec_())
|
||||||
|
|||||||
+2
-2
@@ -31,7 +31,7 @@ logger = bec_logger.logger
|
|||||||
class DeviceManagerOphydValidationDialog(QtWidgets.QDialog):
|
class DeviceManagerOphydValidationDialog(QtWidgets.QDialog):
|
||||||
"""Popup dialog to test Ophyd device configurations interactively."""
|
"""Popup dialog to test Ophyd device configurations interactively."""
|
||||||
|
|
||||||
def __init__(self, parent=None, config: dict | None = None): # type: ignore
|
def __init__(self, parent=None, config: dict | None = None): # type:ignore
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Device Manager Ophyd Test")
|
self.setWindowTitle("Device Manager Ophyd Test")
|
||||||
self._config_status = ConfigStatus.UNKNOWN.value
|
self._config_status = ConfigStatus.UNKNOWN.value
|
||||||
@@ -133,7 +133,7 @@ class DeviceFormDialog(QtWidgets.QDialog):
|
|||||||
# validated: config_status, connection_status
|
# validated: config_status, connection_status
|
||||||
accepted_data = QtCore.Signal(dict, int, int, str, str)
|
accepted_data = QtCore.Signal(dict, int, int, str, str)
|
||||||
|
|
||||||
def __init__(self, parent=None, add_btn_text: str = "Add Device"): # type: ignore
|
def __init__(self, parent=None, add_btn_text: str = "Add Device"): # type:ignore
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
# Track old device name if config is edited
|
# Track old device name if config is edited
|
||||||
self._old_device_name: str = ""
|
self._old_device_name: str = ""
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from typing import TYPE_CHECKING, Any, List, Tuple
|
from typing import TYPE_CHECKING, List, Tuple
|
||||||
|
|
||||||
from bec_lib.logger import bec_logger
|
from bec_lib.logger import bec_logger
|
||||||
from bec_qthemes import apply_theme, material_icon
|
from bec_qthemes import apply_theme, material_icon
|
||||||
|
|||||||
+11
-7
@@ -103,14 +103,16 @@ class CustomBusyWidget(QWidget):
|
|||||||
button_width = int(button_height * aspect_ratio)
|
button_width = int(button_height * aspect_ratio)
|
||||||
self.cancel_button.setFixedSize(button_width, button_height)
|
self.cancel_button.setFixedSize(button_width, button_height)
|
||||||
color = get_accent_colors()
|
color = get_accent_colors()
|
||||||
self.cancel_button.setStyleSheet(f"""
|
self.cancel_button.setStyleSheet(
|
||||||
|
f"""
|
||||||
QPushButton {{
|
QPushButton {{
|
||||||
background-color: {color.emergency.name()};
|
background-color: {color.emergency.name()};
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}}
|
}}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
# Layout
|
# Layout
|
||||||
content_layout = QVBoxLayout(self)
|
content_layout = QVBoxLayout(self)
|
||||||
@@ -126,10 +128,12 @@ class CustomBusyWidget(QWidget):
|
|||||||
bg_color = color._colors.get("BG", None)
|
bg_color = color._colors.get("BG", None)
|
||||||
if bg_color is None: # Fallback if missing
|
if bg_color is None: # Fallback if missing
|
||||||
bg_color = QColor(50, 50, 50, 255)
|
bg_color = QColor(50, 50, 50, 255)
|
||||||
self.setStyleSheet(f"""
|
self.setStyleSheet(
|
||||||
|
f"""
|
||||||
background-color: {bg_color.name()};
|
background-color: {bg_color.name()};
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
def _ui_scale(self) -> int:
|
def _ui_scale(self) -> int:
|
||||||
parent = self.parent()
|
parent = self.parent()
|
||||||
@@ -169,7 +173,7 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
|||||||
self._upload_redis_dialog: UploadRedisDialog | None = None
|
self._upload_redis_dialog: UploadRedisDialog | None = None
|
||||||
self._dialog_validation_connection: QMetaObject.Connection | None = None
|
self._dialog_validation_connection: QMetaObject.Connection | None = None
|
||||||
|
|
||||||
# NOTE: We need here a separate config helper instance to avoid conflicts with
|
# NOTE: We need here a seperate config helper instance to avoid conflicts with
|
||||||
# other communications to REDIS as uploading a config through a CommunicationConfigAction
|
# other communications to REDIS as uploading a config through a CommunicationConfigAction
|
||||||
# will block if we use the config_helper from self.client.config._config_helper
|
# will block if we use the config_helper from self.client.config._config_helper
|
||||||
self._config_helper = config_helper.ConfigHelper(self.client.connector)
|
self._config_helper = config_helper.ConfigHelper(self.client.connector)
|
||||||
@@ -607,8 +611,8 @@ class DeviceManagerDisplayWidget(DockAreaWidget):
|
|||||||
self.device_table_view._is_config_in_sync_with_redis()
|
self.device_table_view._is_config_in_sync_with_redis()
|
||||||
)
|
)
|
||||||
validation_results = self.device_table_view.get_validation_results()
|
validation_results = self.device_table_view.get_validation_results()
|
||||||
for config, config_status, connection_status in validation_results.values():
|
for config, config_status, connnection_status in validation_results.values():
|
||||||
if connection_status == ConnectionStatus.CONNECTED.value:
|
if connnection_status == ConnectionStatus.CONNECTED.value:
|
||||||
self.device_table_view.update_device_validation(
|
self.device_table_view.update_device_validation(
|
||||||
config, config_status, ConnectionStatus.CAN_CONNECT, ""
|
config, config_status, ConnectionStatus.CAN_CONNECT, ""
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
"""Module for Device Manager View."""
|
"""Module for Device Manager View."""
|
||||||
|
|
||||||
from qtpy.QtCore import QRect
|
|
||||||
from qtpy.QtWidgets import QWidget
|
from qtpy.QtWidgets import QWidget
|
||||||
|
|
||||||
from bec_widgets.applications.views.device_manager_view.device_manager_widget import (
|
from bec_widgets.applications.views.device_manager_view.device_manager_widget import (
|
||||||
DeviceManagerWidget,
|
DeviceManagerWidget,
|
||||||
)
|
)
|
||||||
from bec_widgets.applications.views.view import ViewBase, ViewTourSteps
|
from bec_widgets.applications.views.view import ViewBase
|
||||||
from bec_widgets.utils.error_popups import SafeSlot
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
|
|
||||||
|
|
||||||
@@ -20,21 +19,11 @@ class DeviceManagerView(ViewBase):
|
|||||||
parent: QWidget | None = None,
|
parent: QWidget | None = None,
|
||||||
content: QWidget | None = None,
|
content: QWidget | None = None,
|
||||||
*,
|
*,
|
||||||
view_id: str | None = None,
|
id: str | None = None,
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
**kwargs,
|
|
||||||
):
|
):
|
||||||
super().__init__(
|
super().__init__(parent=parent, content=content, id=id, title=title)
|
||||||
parent=parent,
|
self.device_manager_widget = DeviceManagerWidget(parent=self)
|
||||||
content=content,
|
|
||||||
view_id=view_id,
|
|
||||||
title=title,
|
|
||||||
rpc_passthrough_children=False,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
self.device_manager_widget = DeviceManagerWidget(
|
|
||||||
parent=self, rpc_exposed=False, rpc_passthrough_children=False
|
|
||||||
)
|
|
||||||
self.set_content(self.device_manager_widget)
|
self.set_content(self.device_manager_widget)
|
||||||
|
|
||||||
@SafeSlot()
|
@SafeSlot()
|
||||||
@@ -45,110 +34,6 @@ class DeviceManagerView(ViewBase):
|
|||||||
"""
|
"""
|
||||||
self.device_manager_widget.on_enter()
|
self.device_manager_widget.on_enter()
|
||||||
|
|
||||||
def register_tour_steps(self, guided_tour, main_app) -> ViewTourSteps | None:
|
|
||||||
"""Register Device Manager components with the guided tour.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
guided_tour: The GuidedTour instance to register with.
|
|
||||||
main_app: The main application instance (for accessing set_current).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ViewTourSteps | None: Model containing view title and step IDs.
|
|
||||||
"""
|
|
||||||
step_ids = []
|
|
||||||
dm_widget = self.device_manager_widget
|
|
||||||
|
|
||||||
# The device_manager_widget is not yet initialized, so we will register
|
|
||||||
# tour steps for its uninitialized state.
|
|
||||||
|
|
||||||
# Register Load Current Config button
|
|
||||||
def get_load_current():
|
|
||||||
main_app.set_current("device_manager")
|
|
||||||
if dm_widget._initialized is True:
|
|
||||||
return (None, None)
|
|
||||||
return (dm_widget.button_load_current_config, None)
|
|
||||||
|
|
||||||
step_id = guided_tour.register_widget(
|
|
||||||
widget=get_load_current,
|
|
||||||
title="Load Current Config",
|
|
||||||
text="Load the current device configuration from the BEC server.",
|
|
||||||
)
|
|
||||||
step_ids.append(step_id)
|
|
||||||
|
|
||||||
# Register Load Config From File button
|
|
||||||
def get_load_file():
|
|
||||||
main_app.set_current("device_manager")
|
|
||||||
if dm_widget._initialized is True:
|
|
||||||
return (None, None)
|
|
||||||
return (dm_widget.button_load_config_from_file, None)
|
|
||||||
|
|
||||||
step_id = guided_tour.register_widget(
|
|
||||||
widget=get_load_file,
|
|
||||||
title="Load Config From File",
|
|
||||||
text="Load a device configuration from a YAML file on disk.",
|
|
||||||
)
|
|
||||||
step_ids.append(step_id)
|
|
||||||
|
|
||||||
## Register steps for the initialized state
|
|
||||||
# Register main device table
|
|
||||||
def get_device_table():
|
|
||||||
main_app.set_current("device_manager")
|
|
||||||
if dm_widget._initialized is False:
|
|
||||||
return (None, None)
|
|
||||||
return (dm_widget.device_manager_display.device_table_view, None)
|
|
||||||
|
|
||||||
step_id = guided_tour.register_widget(
|
|
||||||
widget=get_device_table,
|
|
||||||
title="Device Table",
|
|
||||||
text="This table displays the config that is prepared to be uploaded to the BEC server. It allows users to review and modify device config settings, and also validate them before uploading to the BEC server.",
|
|
||||||
)
|
|
||||||
step_ids.append(step_id)
|
|
||||||
|
|
||||||
col_text_mapping = {
|
|
||||||
0: "Shows if a device configuration is valid. Automatically validated when adding a new device.",
|
|
||||||
1: "Shows if a device is connectable. Validated on demand.",
|
|
||||||
2: "Device name, unique across all devices within a config.",
|
|
||||||
3: "Device class used to initialize the device on the BEC server.",
|
|
||||||
4: "Defines how BEC treats readings of the device during scans. The options are 'monitored', 'baseline', 'async', 'continuous' or 'on_demand'.",
|
|
||||||
5: "Defines how BEC reacts if a device readback fails. Options are 'raise', 'retry', or 'buffer'.",
|
|
||||||
6: "User-defined tags associated with the device.",
|
|
||||||
7: "A brief description of the device.",
|
|
||||||
8: "Device is enabled when the configuration is loaded.",
|
|
||||||
9: "Device is set to read-only.",
|
|
||||||
10: "This flag allows to configure if the 'trigger' method of the device is called during scans.",
|
|
||||||
}
|
|
||||||
|
|
||||||
# We have at least one device registered
|
|
||||||
def get_device_table_row(column: int):
|
|
||||||
main_app.set_current("device_manager")
|
|
||||||
if dm_widget._initialized is False:
|
|
||||||
return (None, None)
|
|
||||||
table = dm_widget.device_manager_display.device_table_view.table
|
|
||||||
header = table.horizontalHeader()
|
|
||||||
x = header.sectionViewportPosition(column)
|
|
||||||
table.horizontalScrollBar().setValue(x)
|
|
||||||
# Recompute after scrolling
|
|
||||||
x = header.sectionViewportPosition(column)
|
|
||||||
w = header.sectionSize(column)
|
|
||||||
h = header.height()
|
|
||||||
rect = QRect(x, 0, w, h)
|
|
||||||
top_left = header.viewport().mapTo(main_app, rect.topLeft())
|
|
||||||
|
|
||||||
return (QRect(top_left, rect.size()), col_text_mapping.get(column, ""))
|
|
||||||
|
|
||||||
for col, text in col_text_mapping.items():
|
|
||||||
step_id = guided_tour.register_widget(
|
|
||||||
widget=lambda col=col: get_device_table_row(col),
|
|
||||||
title=f"{dm_widget.device_manager_display.device_table_view.table.horizontalHeaderItem(col).text()}",
|
|
||||||
text=text,
|
|
||||||
)
|
|
||||||
step_ids.append(step_id)
|
|
||||||
|
|
||||||
if not step_ids:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return ViewTourSteps(view_title="Device Manager", step_ids=step_ids)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
import sys
|
import sys
|
||||||
@@ -180,7 +65,7 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
_app.add_view(
|
_app.add_view(
|
||||||
icon="display_settings",
|
icon="display_settings",
|
||||||
title="Device Manager",
|
title="Device Manager",
|
||||||
view_id="device_manager",
|
id="device_manager",
|
||||||
widget=device_manager_view.device_manager_widget,
|
widget=device_manager_view.device_manager_widget,
|
||||||
mini_text="DM",
|
mini_text="DM",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ class DeviceManagerWidget(BECWidget, QtWidgets.QWidget):
|
|||||||
|
|
||||||
RPC = False
|
RPC = False
|
||||||
|
|
||||||
def __init__(self, parent=None, client=None, **kwargs):
|
def __init__(self, parent=None, client=None):
|
||||||
super().__init__(parent=parent, client=client, **kwargs)
|
super().__init__(parent=parent, client=client)
|
||||||
self.stacked_layout = QtWidgets.QStackedLayout()
|
self.stacked_layout = QtWidgets.QStackedLayout()
|
||||||
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
|
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
self.stacked_layout.setSpacing(0)
|
self.stacked_layout.setSpacing(0)
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
from qtpy.QtWidgets import QWidget
|
|
||||||
|
|
||||||
from bec_widgets.applications.views.view import ViewBase
|
|
||||||
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
|
|
||||||
|
|
||||||
|
|
||||||
class DockAreaView(ViewBase):
|
|
||||||
"""
|
|
||||||
Modular dock area view for arranging and managing multiple dockable widgets.
|
|
||||||
"""
|
|
||||||
|
|
||||||
RPC_CONTENT_CLASS = BECDockArea
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
parent: QWidget | None = None,
|
|
||||||
content: QWidget | None = None,
|
|
||||||
*,
|
|
||||||
view_id: str | None = None,
|
|
||||||
title: str | None = None,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
super().__init__(parent=parent, content=content, view_id=view_id, title=title, **kwargs)
|
|
||||||
self.dock_area = BECDockArea(
|
|
||||||
self,
|
|
||||||
profile_namespace="bec",
|
|
||||||
auto_profile_namespace=False,
|
|
||||||
object_name="DockArea",
|
|
||||||
rpc_exposed=False,
|
|
||||||
)
|
|
||||||
self.set_content(self.dock_area)
|
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from qtpy.QtCore import QEventLoop
|
from qtpy.QtCore import QEventLoop
|
||||||
from qtpy.QtWidgets import (
|
from qtpy.QtWidgets import (
|
||||||
QDialog,
|
QDialog,
|
||||||
@@ -17,26 +14,13 @@ from qtpy.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from bec_widgets import BECWidget
|
|
||||||
from bec_widgets.utils.error_popups import SafeSlot
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
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
|
||||||
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
|
||||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||||
|
|
||||||
|
|
||||||
class ViewTourSteps(BaseModel):
|
class ViewBase(QWidget):
|
||||||
"""Model representing tour steps for a view.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
view_title: The human-readable title of the view.
|
|
||||||
step_ids: List of registered step IDs in the order they should appear.
|
|
||||||
"""
|
|
||||||
|
|
||||||
view_title: str
|
|
||||||
step_ids: List[str]
|
|
||||||
|
|
||||||
|
|
||||||
class ViewBase(BECWidget, QWidget):
|
|
||||||
"""Wrapper for a content widget used inside the main app's stacked view.
|
"""Wrapper for a content widget used inside the main app's stacked view.
|
||||||
|
|
||||||
Subclasses can implement `on_enter` and `on_exit` to run custom logic when the view becomes visible or is about to be hidden.
|
Subclasses can implement `on_enter` and `on_exit` to run custom logic when the view becomes visible or is about to be hidden.
|
||||||
@@ -44,28 +28,21 @@ class ViewBase(BECWidget, QWidget):
|
|||||||
Args:
|
Args:
|
||||||
content (QWidget): The actual view widget to display.
|
content (QWidget): The actual view widget to display.
|
||||||
parent (QWidget | None): Parent widget.
|
parent (QWidget | None): Parent widget.
|
||||||
view_id (str | None): Optional view view_id, useful for debugging or introspection.
|
id (str | None): Optional view id, useful for debugging or introspection.
|
||||||
title (str | None): Optional human-readable title.
|
title (str | None): Optional human-readable title.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
RPC = True
|
|
||||||
PLUGIN = False
|
|
||||||
USER_ACCESS = ["activate"]
|
|
||||||
RPC_CONTENT_CLASS: type[QWidget] | None = None
|
|
||||||
RPC_CONTENT_ATTR = "content"
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
parent: QWidget | None = None,
|
parent: QWidget | None = None,
|
||||||
content: QWidget | None = None,
|
content: QWidget | None = None,
|
||||||
*,
|
*,
|
||||||
view_id: str | None = None,
|
id: str | None = None,
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
**kwargs,
|
|
||||||
):
|
):
|
||||||
super().__init__(parent=parent, **kwargs)
|
super().__init__(parent=parent)
|
||||||
self.content: QWidget | None = None
|
self.content: QWidget | None = None
|
||||||
self.view_id = view_id
|
self.view_id = id
|
||||||
self.view_title = title
|
self.view_title = title
|
||||||
|
|
||||||
lay = QVBoxLayout(self)
|
lay = QVBoxLayout(self)
|
||||||
@@ -99,41 +76,6 @@ class ViewBase(BECWidget, QWidget):
|
|||||||
"""
|
"""
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@SafeSlot()
|
|
||||||
def activate(self) -> None:
|
|
||||||
"""Switch the parent application to this view."""
|
|
||||||
if not self.view_id:
|
|
||||||
raise ValueError("Cannot switch view without a view_id.")
|
|
||||||
|
|
||||||
parent = self.parent()
|
|
||||||
while parent is not None:
|
|
||||||
if hasattr(parent, "set_current"):
|
|
||||||
parent.set_current(self.view_id)
|
|
||||||
return
|
|
||||||
parent = parent.parent()
|
|
||||||
raise RuntimeError("Could not find a parent application with set_current().")
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
if self.content is not None:
|
|
||||||
self.content.close()
|
|
||||||
self.content.deleteLater()
|
|
||||||
super().cleanup()
|
|
||||||
|
|
||||||
def register_tour_steps(self, guided_tour, main_app) -> ViewTourSteps | None:
|
|
||||||
"""Register this view's components with the guided tour.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
guided_tour: The GuidedTour instance to register with.
|
|
||||||
main_app: The main application instance (for accessing set_current).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ViewTourSteps | None: A model containing the view title and step IDs,
|
|
||||||
or None if this view has no tour steps.
|
|
||||||
|
|
||||||
Override this method in subclasses to register view-specific components.
|
|
||||||
"""
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
####################################################################################################
|
####################################################################################################
|
||||||
# Example views for demonstration/testing purposes
|
# Example views for demonstration/testing purposes
|
||||||
@@ -160,17 +102,17 @@ class WaveformViewPopup(ViewBase): # pragma: no cover
|
|||||||
self.device_edit.insertItem(0, "")
|
self.device_edit.insertItem(0, "")
|
||||||
self.device_edit.setEditable(True)
|
self.device_edit.setEditable(True)
|
||||||
self.device_edit.setCurrentIndex(0)
|
self.device_edit.setCurrentIndex(0)
|
||||||
self.signal_edit = SignalComboBox(parent=self)
|
self.entry_edit = SignalComboBox(parent=self)
|
||||||
self.signal_edit.include_config_signals = False
|
self.entry_edit.include_config_signals = False
|
||||||
self.signal_edit.insertItem(0, "")
|
self.entry_edit.insertItem(0, "")
|
||||||
self.signal_edit.setEditable(True)
|
self.entry_edit.setEditable(True)
|
||||||
self.device_edit.currentTextChanged.connect(self.signal_edit.set_device)
|
self.device_edit.currentTextChanged.connect(self.entry_edit.set_device)
|
||||||
self.device_edit.device_reset.connect(self.signal_edit.reset_selection)
|
self.device_edit.device_reset.connect(self.entry_edit.reset_selection)
|
||||||
|
|
||||||
form = QFormLayout()
|
form = QFormLayout()
|
||||||
form.addRow(label)
|
form.addRow(label)
|
||||||
form.addRow("Device", self.device_edit)
|
form.addRow("Device", self.device_edit)
|
||||||
form.addRow("Signal", self.signal_edit)
|
form.addRow("Signal", self.entry_edit)
|
||||||
|
|
||||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dialog)
|
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dialog)
|
||||||
buttons.accepted.connect(dialog.accept)
|
buttons.accepted.connect(dialog.accept)
|
||||||
@@ -182,7 +124,7 @@ class WaveformViewPopup(ViewBase): # pragma: no cover
|
|||||||
|
|
||||||
if dialog.exec_() == QDialog.Accepted:
|
if dialog.exec_() == QDialog.Accepted:
|
||||||
self.waveform.plot(
|
self.waveform.plot(
|
||||||
device_y=self.device_edit.currentText(), signal_y=self.signal_edit.currentText()
|
y_name=self.device_edit.currentText(), y_entry=self.entry_edit.currentText()
|
||||||
)
|
)
|
||||||
|
|
||||||
@SafeSlot()
|
@SafeSlot()
|
||||||
@@ -307,7 +249,7 @@ class WaveformViewInline(ViewBase): # pragma: no cover
|
|||||||
dev = self.device_edit.currentText()
|
dev = self.device_edit.currentText()
|
||||||
sig = self.entry_edit.currentText()
|
sig = self.entry_edit.currentText()
|
||||||
if dev and sig:
|
if dev and sig:
|
||||||
self.waveform.plot(device_y=dev, signal_y=sig)
|
self.waveform.plot(y_name=dev, y_entry=sig)
|
||||||
self.stack.setCurrentIndex(1)
|
self.stack.setCurrentIndex(1)
|
||||||
|
|
||||||
def _show_waveform_without_changes(self):
|
def _show_waveform_without_changes(self):
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
from bec_widgets.cli.rpc import rpc_base
|
|
||||||
|
|||||||
+170
-587
File diff suppressed because it is too large
Load Diff
@@ -10,9 +10,9 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
from typing import TYPE_CHECKING, Callable, Literal, TypeAlias, cast
|
from typing import TYPE_CHECKING, Literal, TypeAlias, cast
|
||||||
|
|
||||||
from bec_lib.endpoints import EndpointInfo, MessageEndpoints
|
from bec_lib.endpoints import MessageEndpoints
|
||||||
from bec_lib.logger import bec_logger
|
from bec_lib.logger import bec_logger
|
||||||
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
|
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
@@ -232,11 +232,6 @@ class BECGuiClient(RPCBase):
|
|||||||
"""The launcher object."""
|
"""The launcher object."""
|
||||||
return RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self, object_name="launcher")
|
return RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self, object_name="launcher")
|
||||||
|
|
||||||
def _safe_register_stream(self, endpoint: EndpointInfo, cb: Callable, **kwargs):
|
|
||||||
"""Check if already registered for registration in idempotent functions."""
|
|
||||||
if not self._client.connector.any_stream_is_registered(endpoint, cb=cb):
|
|
||||||
self._client.connector.register(endpoint, cb=cb, **kwargs)
|
|
||||||
|
|
||||||
def connect_to_gui_server(self, gui_id: str) -> None:
|
def connect_to_gui_server(self, gui_id: str) -> None:
|
||||||
"""Connect to a GUI server"""
|
"""Connect to a GUI server"""
|
||||||
# Unregister the old callback
|
# Unregister the old callback
|
||||||
@@ -252,9 +247,10 @@ class BECGuiClient(RPCBase):
|
|||||||
self._ipython_registry = {}
|
self._ipython_registry = {}
|
||||||
|
|
||||||
# Register the new callback
|
# Register the new callback
|
||||||
self._safe_register_stream(
|
self._client.connector.register(
|
||||||
MessageEndpoints.gui_registry_state(self._gui_id),
|
MessageEndpoints.gui_registry_state(self._gui_id),
|
||||||
cb=self._handle_registry_update,
|
cb=self._handle_registry_update,
|
||||||
|
parent=self,
|
||||||
from_start=True,
|
from_start=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -301,32 +297,14 @@ class BECGuiClient(RPCBase):
|
|||||||
return self._raise_all()
|
return self._raise_all()
|
||||||
return self._start(wait=wait)
|
return self._start(wait=wait)
|
||||||
|
|
||||||
def change_theme(self, theme: Literal["light", "dark"] | None = None) -> None:
|
|
||||||
"""
|
|
||||||
Apply a GUI theme or toggle between dark and light.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
theme(Literal["light", "dark"] | None): Theme to apply. If None, the current
|
|
||||||
theme is fetched from the GUI and toggled.
|
|
||||||
"""
|
|
||||||
if not self._check_if_server_is_alive():
|
|
||||||
self._start(wait=True)
|
|
||||||
|
|
||||||
with wait_for_server(self):
|
|
||||||
if theme is None:
|
|
||||||
current_theme = self.launcher._run_rpc("fetch_theme")
|
|
||||||
next_theme = "light" if current_theme == "dark" else "dark"
|
|
||||||
else:
|
|
||||||
next_theme = theme
|
|
||||||
self.launcher._run_rpc("change_theme", theme=next_theme)
|
|
||||||
|
|
||||||
def new(
|
def new(
|
||||||
self,
|
self,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
wait: bool = True,
|
wait: bool = True,
|
||||||
geometry: tuple[int, int, int, int] | None = None,
|
geometry: tuple[int, int, int, int] | None = None,
|
||||||
launch_script: str = "dock_area",
|
launch_script: str = "dock_area",
|
||||||
startup_profile: str | Literal["restore", "skip"] | None = None,
|
profile: str | None = None,
|
||||||
|
start_empty: bool = False,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> client.AdvancedDockArea:
|
) -> client.AdvancedDockArea:
|
||||||
"""Create a new top-level dock area.
|
"""Create a new top-level dock area.
|
||||||
@@ -336,81 +314,48 @@ class BECGuiClient(RPCBase):
|
|||||||
wait(bool, optional): Whether to wait for the server to start. Defaults to True.
|
wait(bool, optional): Whether to wait for the server to start. Defaults to True.
|
||||||
geometry(tuple[int, int, int, int] | None): The geometry of the dock area (pos_x, pos_y, w, h).
|
geometry(tuple[int, int, int, int] | None): The geometry of the dock area (pos_x, pos_y, w, h).
|
||||||
launch_script(str): The launch script to use. Defaults to "dock_area".
|
launch_script(str): The launch script to use. Defaults to "dock_area".
|
||||||
startup_profile(str | Literal["restore", "skip"] | None): Startup mode for
|
profile(str | None): The profile name to load. If None, loads the "general" profile.
|
||||||
the dock area:
|
Use a profile name to load a specific saved profile.
|
||||||
- None: start in transient empty workspace
|
start_empty(bool): If True, start with an empty dock area when loading specified profile.
|
||||||
- "restore": restore last-used profile
|
|
||||||
- "skip": skip profile initialization
|
|
||||||
- "<name>": load the named profile
|
|
||||||
**kwargs: Additional keyword arguments passed to the dock area.
|
**kwargs: Additional keyword arguments passed to the dock area.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
client.AdvancedDockArea: The new dock area.
|
client.AdvancedDockArea: The new dock area.
|
||||||
|
|
||||||
Examples:
|
Note:
|
||||||
>>> gui.new() # Start with an empty unsaved workspace
|
The "general" profile is mandatory and will always exist. If manually deleted,
|
||||||
>>> gui.new(startup_profile="restore") # Restore last profile
|
it will be automatically recreated.
|
||||||
>>> gui.new(startup_profile="my_profile") # Load explicit profile
|
|
||||||
"""
|
|
||||||
if "profile" in kwargs or "start_empty" in kwargs:
|
|
||||||
raise TypeError(
|
|
||||||
"gui.new() no longer accepts 'profile' or 'start_empty'. Use 'startup_profile' instead."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> gui.new() # Start with the "general" profile
|
||||||
|
>>> gui.new(profile="my_profile") # Load specific profile, if profile does not exist, the new profile is created empty with specified name
|
||||||
|
>>> gui.new(start_empty=True) # Start with "general" profile but empty dock area
|
||||||
|
>>> gui.new(profile="my_profile", start_empty=True) # Start with "my_profile" profile but empty dock area
|
||||||
|
"""
|
||||||
if not self._check_if_server_is_alive():
|
if not self._check_if_server_is_alive():
|
||||||
self.start(wait=True)
|
self.start(wait=True)
|
||||||
if wait:
|
if wait:
|
||||||
with wait_for_server(self):
|
with wait_for_server(self):
|
||||||
return self._new_impl(
|
widget = self.launcher._run_rpc(
|
||||||
name=name,
|
"launch",
|
||||||
geometry=geometry,
|
|
||||||
launch_script=launch_script,
|
launch_script=launch_script,
|
||||||
startup_profile=startup_profile,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
return self._new_impl(
|
|
||||||
name=name,
|
|
||||||
geometry=geometry,
|
|
||||||
launch_script=launch_script,
|
|
||||||
startup_profile=startup_profile,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _new_impl(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
name: str | None,
|
|
||||||
geometry: tuple[int, int, int, int] | None,
|
|
||||||
launch_script: str,
|
|
||||||
startup_profile: str | Literal["restore", "skip"] | None,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
if launch_script == "dock_area":
|
|
||||||
try:
|
|
||||||
return self.launcher._run_rpc(
|
|
||||||
"system.launch_dock_area",
|
|
||||||
name=name,
|
name=name,
|
||||||
geometry=geometry,
|
geometry=geometry,
|
||||||
startup_profile=startup_profile,
|
profile=profile,
|
||||||
|
start_empty=start_empty,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
) # pylint: disable=protected-access
|
||||||
except ValueError as exc:
|
return widget
|
||||||
error = str(exc)
|
widget = self.launcher._run_rpc(
|
||||||
if (
|
|
||||||
"Unknown system RPC method: system.launch_dock_area" not in error
|
|
||||||
and "has no attribute 'system.launch_dock_area'" not in error
|
|
||||||
):
|
|
||||||
raise
|
|
||||||
logger.debug("Server does not support system.launch_dock_area; using launcher RPC")
|
|
||||||
|
|
||||||
return self.launcher._run_rpc(
|
|
||||||
"launch",
|
"launch",
|
||||||
launch_script=launch_script,
|
launch_script=launch_script,
|
||||||
name=name,
|
name=name,
|
||||||
geometry=geometry,
|
geometry=geometry,
|
||||||
startup_profile=startup_profile,
|
profile=profile,
|
||||||
|
start_empty=start_empty,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) # pylint: disable=protected-access
|
) # pylint: disable=protected-access
|
||||||
|
return widget
|
||||||
|
|
||||||
def delete(self, name: str) -> None:
|
def delete(self, name: str) -> None:
|
||||||
"""Delete a dock area and its parent window.
|
"""Delete a dock area and its parent window.
|
||||||
@@ -535,14 +480,20 @@ class BECGuiClient(RPCBase):
|
|||||||
|
|
||||||
def _start(self, wait: bool = False) -> None:
|
def _start(self, wait: bool = False) -> None:
|
||||||
self._killed = False
|
self._killed = False
|
||||||
self._safe_register_stream(
|
self._client.connector.register(
|
||||||
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
|
MessageEndpoints.gui_registry_state(self._gui_id),
|
||||||
|
cb=self._handle_registry_update,
|
||||||
|
parent=self,
|
||||||
)
|
)
|
||||||
return self._start_server(wait=wait)
|
return self._start_server(wait=wait)
|
||||||
|
|
||||||
def _handle_registry_update(self, msg: dict[str, GUIRegistryStateMessage]) -> None:
|
@staticmethod
|
||||||
|
def _handle_registry_update(
|
||||||
|
msg: dict[str, GUIRegistryStateMessage], parent: BECGuiClient
|
||||||
|
) -> None:
|
||||||
# This was causing a deadlock during shutdown, not sure why.
|
# This was causing a deadlock during shutdown, not sure why.
|
||||||
# with self._lock:
|
# with self._lock:
|
||||||
|
self = parent
|
||||||
self._server_registry = cast(dict[str, RegistryState], msg["data"].state)
|
self._server_registry = cast(dict[str, RegistryState], msg["data"].state)
|
||||||
self._update_dynamic_namespace(self._server_registry)
|
self._update_dynamic_namespace(self._server_registry)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import inspect
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import get_overloads
|
|
||||||
|
|
||||||
import black
|
import black
|
||||||
import isort
|
import isort
|
||||||
@@ -19,6 +18,20 @@ from bec_widgets.utils.plugin_utils import BECClassContainer, get_custom_classes
|
|||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
|
|
||||||
|
if sys.version_info >= (3, 11):
|
||||||
|
from typing import get_overloads
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
"Python version is less than 3.11, using dummy function for get_overloads. "
|
||||||
|
"If you want to use the real function 'typing.get_overloads()', please use Python 3.11 or later."
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_overloads(_obj):
|
||||||
|
"""
|
||||||
|
Dummy function for Python versions before 3.11.
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
class ClientGenerator:
|
class ClientGenerator:
|
||||||
def __init__(self, base=False):
|
def __init__(self, base=False):
|
||||||
@@ -41,7 +54,7 @@ from __future__ import annotations
|
|||||||
from bec_lib.logger import bec_logger
|
from bec_lib.logger import bec_logger
|
||||||
|
|
||||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
|
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
|
||||||
{"from bec_widgets.utils.bec_plugin_helper import get_plugin_client_module" if self._base else ""}
|
{"from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module" if self._base else ""}
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
|
|
||||||
@@ -81,7 +94,7 @@ logger = bec_logger.logger
|
|||||||
if self._base:
|
if self._base:
|
||||||
self.content += """
|
self.content += """
|
||||||
class _WidgetsEnumType(str, enum.Enum):
|
class _WidgetsEnumType(str, enum.Enum):
|
||||||
\"\"\" Enum for the available widgets, to be generated programmatically \"\"\"
|
\"\"\" Enum for the available widgets, to be generated programatically \"\"\"
|
||||||
...
|
...
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -98,19 +111,27 @@ _Widgets = {
|
|||||||
self.content += """
|
self.content += """
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
_plugin_widgets = get_all_plugin_widgets().as_dict()
|
||||||
plugin_client = get_plugin_client_module()
|
plugin_client = get_plugin_client_module()
|
||||||
|
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
|
||||||
|
|
||||||
|
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
|
||||||
|
for _widget in _overlap:
|
||||||
|
logger.warning(f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !")
|
||||||
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
|
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
|
||||||
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
|
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
|
||||||
if plugin_name not in _Widgets:
|
|
||||||
_Widgets[plugin_name] = plugin_name
|
|
||||||
if plugin_name in globals():
|
if plugin_name in globals():
|
||||||
|
conflicting_file = (
|
||||||
|
inspect.getfile(_plugin_widgets[plugin_name])
|
||||||
|
if plugin_name in _plugin_widgets
|
||||||
|
else f"{plugin_client}"
|
||||||
|
)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Plugin widget {plugin_name} in {plugin_class._IMPORT_MODULE} conflicts with a built-in class!"
|
f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
else:
|
if plugin_name not in _overlap:
|
||||||
globals()[plugin_name] = plugin_class
|
globals()[plugin_name] = plugin_class
|
||||||
Widgets = _WidgetsEnumType("Widgets", _Widgets)
|
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(e))}")
|
logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(e))}")
|
||||||
"""
|
"""
|
||||||
@@ -125,8 +146,12 @@ except ImportError as e:
|
|||||||
|
|
||||||
class_name = cls.__name__
|
class_name = cls.__name__
|
||||||
|
|
||||||
self.content += f"""
|
if class_name == "BECDockArea":
|
||||||
class {class_name}(RPCBase):\n"""
|
self.content += f"""
|
||||||
|
class {class_name}(RPCBase):"""
|
||||||
|
else:
|
||||||
|
self.content += f"""
|
||||||
|
class {class_name}(RPCBase):"""
|
||||||
|
|
||||||
if cls.__doc__:
|
if cls.__doc__:
|
||||||
# We only want the first line of the docstring
|
# We only want the first line of the docstring
|
||||||
@@ -137,11 +162,19 @@ class {class_name}(RPCBase):\n"""
|
|||||||
else:
|
else:
|
||||||
class_docs = cls.__doc__.split("\n")[1]
|
class_docs = cls.__doc__.split("\n")[1]
|
||||||
self.content += f"""
|
self.content += f"""
|
||||||
\"\"\"{class_docs}\"\"\"\n"""
|
\"\"\"{class_docs}\"\"\"
|
||||||
user_access_entries = self._get_user_access_entries(cls)
|
"""
|
||||||
self.content += f' _IMPORT_MODULE="{cls.__module__}"\n'
|
if not cls.USER_ACCESS:
|
||||||
for method_entry in user_access_entries:
|
self.content += """...
|
||||||
method, obj, is_property_setter = self._resolve_method_object(cls, method_entry)
|
"""
|
||||||
|
|
||||||
|
for method in cls.USER_ACCESS:
|
||||||
|
is_property_setter = False
|
||||||
|
obj = getattr(cls, method, None)
|
||||||
|
if obj is None:
|
||||||
|
obj = getattr(cls, method.split(".setter")[0], None)
|
||||||
|
is_property_setter = True
|
||||||
|
method = method.split(".setter")[0]
|
||||||
if obj is None:
|
if obj is None:
|
||||||
raise AttributeError(
|
raise AttributeError(
|
||||||
f"Method {method} not found in class {cls.__name__}. "
|
f"Method {method} not found in class {cls.__name__}. "
|
||||||
@@ -183,34 +216,6 @@ class {class_name}(RPCBase):\n"""
|
|||||||
{doc}
|
{doc}
|
||||||
\"\"\""""
|
\"\"\""""
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_user_access_entries(cls) -> list[str]:
|
|
||||||
entries = list(getattr(cls, "USER_ACCESS", []))
|
|
||||||
content_cls = getattr(cls, "RPC_CONTENT_CLASS", None)
|
|
||||||
if content_cls is not None:
|
|
||||||
entries.extend(getattr(content_cls, "USER_ACCESS", []))
|
|
||||||
return list(dict.fromkeys(entries))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _resolve_method_object(cls, method_entry: str):
|
|
||||||
method_name = method_entry
|
|
||||||
is_property_setter = False
|
|
||||||
|
|
||||||
if method_entry.endswith(".setter"):
|
|
||||||
is_property_setter = True
|
|
||||||
method_name = method_entry.split(".setter")[0]
|
|
||||||
|
|
||||||
candidate_classes = [cls]
|
|
||||||
content_cls = getattr(cls, "RPC_CONTENT_CLASS", None)
|
|
||||||
if content_cls is not None:
|
|
||||||
candidate_classes.append(content_cls)
|
|
||||||
|
|
||||||
for candidate_cls in candidate_classes:
|
|
||||||
obj = getattr(candidate_cls, method_name, None)
|
|
||||||
if obj is not None:
|
|
||||||
return method_name, obj, is_property_setter
|
|
||||||
return method_name, None, is_property_setter
|
|
||||||
|
|
||||||
def _rpc_call(self, timeout_info: dict[str, float | None]):
|
def _rpc_call(self, timeout_info: dict[str, float | None]):
|
||||||
"""
|
"""
|
||||||
Decorator to mark a method as an RPC call.
|
Decorator to mark a method as an RPC call.
|
||||||
@@ -286,8 +291,7 @@ def main():
|
|||||||
|
|
||||||
client_path = module_dir / client_subdir / "client.py"
|
client_path = module_dir / client_subdir / "client.py"
|
||||||
|
|
||||||
packages = ("widgets", "applications") if module_name == "bec_widgets" else ("widgets",)
|
rpc_classes = get_custom_classes(module_name)
|
||||||
rpc_classes = get_custom_classes(module_name, packages=packages)
|
|
||||||
logger.info(f"Obtained classes with RPC objects: {rpc_classes!r}")
|
logger.info(f"Obtained classes with RPC objects: {rpc_classes!r}")
|
||||||
|
|
||||||
generator = ClientGenerator(base=module_name == "bec_widgets")
|
generator = ClientGenerator(base=module_name == "bec_widgets")
|
||||||
@@ -248,7 +248,9 @@ class RPCBase:
|
|||||||
self._rpc_response = None
|
self._rpc_response = None
|
||||||
self._msg_wait_event.clear()
|
self._msg_wait_event.clear()
|
||||||
self._client.connector.register(
|
self._client.connector.register(
|
||||||
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
|
MessageEndpoints.gui_instruction_response(request_id),
|
||||||
|
cb=self._on_rpc_response,
|
||||||
|
parent=self,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
|
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
|
||||||
@@ -274,10 +276,11 @@ class RPCBase:
|
|||||||
self._rpc_response = None
|
self._rpc_response = None
|
||||||
return self._create_widget_from_msg_result(msg_result)
|
return self._create_widget_from_msg_result(msg_result)
|
||||||
|
|
||||||
def _on_rpc_response(self, msg_obj: MessageObject) -> None:
|
@staticmethod
|
||||||
|
def _on_rpc_response(msg_obj: MessageObject, parent: RPCBase) -> None:
|
||||||
msg = cast(messages.RequestResponseMessage, msg_obj.value)
|
msg = cast(messages.RequestResponseMessage, msg_obj.value)
|
||||||
self._rpc_response = msg
|
parent._rpc_response = msg
|
||||||
self._msg_wait_event.set()
|
parent._msg_wait_event.set()
|
||||||
|
|
||||||
def _create_widget_from_msg_result(self, msg_result):
|
def _create_widget_from_msg_result(self, msg_result):
|
||||||
if msg_result is None:
|
if msg_result is None:
|
||||||
@@ -289,11 +292,6 @@ class RPCBase:
|
|||||||
return {
|
return {
|
||||||
key: self._create_widget_from_msg_result(val) for key, val in msg_result.items()
|
key: self._create_widget_from_msg_result(val) for key, val in msg_result.items()
|
||||||
}
|
}
|
||||||
rpc_enabled = msg_result.get("__rpc__", True)
|
|
||||||
if rpc_enabled is False:
|
|
||||||
return None
|
|
||||||
|
|
||||||
msg_result = dict(msg_result)
|
|
||||||
cls = msg_result.pop("widget_class", None)
|
cls = msg_result.pop("widget_class", None)
|
||||||
msg_result.pop("__rpc__", None)
|
msg_result.pop("__rpc__", None)
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,7 @@ class RPCWidgetHandler:
|
|||||||
None
|
None
|
||||||
"""
|
"""
|
||||||
self._widget_classes = (
|
self._widget_classes = (
|
||||||
get_custom_classes("bec_widgets", packages=("widgets", "applications"))
|
get_custom_classes("bec_widgets") + get_all_plugin_widgets()
|
||||||
+ get_all_plugin_widgets()
|
|
||||||
).as_dict(IGNORE_WIDGETS)
|
).as_dict(IGNORE_WIDGETS)
|
||||||
|
|
||||||
def create_widget(self, widget_type, **kwargs) -> BECWidget:
|
def create_widget(self, widget_type, **kwargs) -> BECWidget:
|
||||||
@@ -8,7 +8,6 @@ import sys
|
|||||||
from contextlib import redirect_stderr, redirect_stdout
|
from contextlib import redirect_stderr, redirect_stdout
|
||||||
|
|
||||||
import darkdetect
|
import darkdetect
|
||||||
import shiboken6
|
|
||||||
from bec_lib.logger import bec_logger
|
from bec_lib.logger import bec_logger
|
||||||
from bec_lib.service_config import ServiceConfig
|
from bec_lib.service_config import ServiceConfig
|
||||||
from bec_qthemes import apply_theme
|
from bec_qthemes import apply_theme
|
||||||
@@ -19,8 +18,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
|
||||||
|
|
||||||
@@ -94,7 +93,6 @@ class GUIServer:
|
|||||||
"""
|
"""
|
||||||
Run the GUI server.
|
Run the GUI server.
|
||||||
"""
|
"""
|
||||||
logger.info("Starting GUIServer", repr(self))
|
|
||||||
self.app = QApplication(sys.argv)
|
self.app = QApplication(sys.argv)
|
||||||
if darkdetect.isDark():
|
if darkdetect.isDark():
|
||||||
apply_theme("dark")
|
apply_theme("dark")
|
||||||
@@ -103,11 +101,11 @@ class GUIServer:
|
|||||||
|
|
||||||
self.app.setApplicationName("BEC")
|
self.app.setApplicationName("BEC")
|
||||||
self.app.gui_id = self.gui_id # type: ignore
|
self.app.gui_id = self.gui_id # type: ignore
|
||||||
self.app.gui_server = self # type: ignore # make server accessible from QApplication for getattr in widgets
|
|
||||||
self.setup_bec_icon()
|
self.setup_bec_icon()
|
||||||
|
|
||||||
service_config = self._get_service_config()
|
service_config = self._get_service_config()
|
||||||
self.dispatcher = BECDispatcher(config=service_config, gui_id=self.gui_id)
|
self.dispatcher = BECDispatcher(config=service_config, gui_id=self.gui_id)
|
||||||
|
# self.dispatcher.start_cli_server(gui_id=self.gui_id)
|
||||||
|
|
||||||
if self.gui_class:
|
if self.gui_class:
|
||||||
self.launcher_window = LaunchWindow(
|
self.launcher_window = LaunchWindow(
|
||||||
@@ -120,7 +118,7 @@ class GUIServer:
|
|||||||
self.launcher_window.setAttribute(Qt.WA_ShowWithoutActivating) # type: ignore
|
self.launcher_window.setAttribute(Qt.WA_ShowWithoutActivating) # type: ignore
|
||||||
|
|
||||||
self.app.aboutToQuit.connect(self.shutdown)
|
self.app.aboutToQuit.connect(self.shutdown)
|
||||||
self.app.setQuitOnLastWindowClosed(True)
|
self.app.setQuitOnLastWindowClosed(False)
|
||||||
|
|
||||||
def sigint_handler(*args):
|
def sigint_handler(*args):
|
||||||
# display message, for people to let it terminate gracefully
|
# display message, for people to let it terminate gracefully
|
||||||
@@ -129,7 +127,8 @@ class GUIServer:
|
|||||||
with RPCRegister.delayed_broadcast():
|
with RPCRegister.delayed_broadcast():
|
||||||
for widget in QApplication.instance().topLevelWidgets(): # type: ignore
|
for widget in QApplication.instance().topLevelWidgets(): # type: ignore
|
||||||
widget.close()
|
widget.close()
|
||||||
self.shutdown()
|
if self.app:
|
||||||
|
self.app.quit()
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, sigint_handler)
|
signal.signal(signal.SIGINT, sigint_handler)
|
||||||
signal.signal(signal.SIGTERM, sigint_handler)
|
signal.signal(signal.SIGTERM, sigint_handler)
|
||||||
@@ -150,10 +149,9 @@ class GUIServer:
|
|||||||
self.app.setWindowIcon(icon)
|
self.app.setWindowIcon(icon)
|
||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
logger.info("Shutdown GUIServer", repr(self))
|
"""
|
||||||
if self.launcher_window and shiboken6.isValid(self.launcher_window):
|
Shutdown the GUI server.
|
||||||
self.launcher_window.close()
|
"""
|
||||||
self.launcher_window.deleteLater()
|
|
||||||
if pylsp_server.is_running():
|
if pylsp_server.is_running():
|
||||||
pylsp_server.stop()
|
pylsp_server.stop()
|
||||||
if self.dispatcher:
|
if self.dispatcher:
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
# pylint: skip-file
|
# pylint: skip-file
|
||||||
|
import json
|
||||||
|
import time
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import h5py
|
||||||
|
from bec_lib import messages
|
||||||
|
from bec_lib.bec_service import messages
|
||||||
from bec_lib.config_helper import ConfigHelper
|
from bec_lib.config_helper import ConfigHelper
|
||||||
from bec_lib.device import Device as BECDevice
|
from bec_lib.device import Device as BECDevice
|
||||||
from bec_lib.device import Positioner as BECPositioner
|
from bec_lib.device import Positioner as BECPositioner
|
||||||
from bec_lib.device import ReadoutPriority
|
from bec_lib.device import ReadoutPriority
|
||||||
from bec_lib.devicemanager import DeviceContainer
|
from bec_lib.devicemanager import DeviceContainer
|
||||||
|
from bec_lib.messages import _StoredDataInfo
|
||||||
|
from bec_lib.scan_history import ScanHistory
|
||||||
|
from qtpy.QtCore import QEvent, QEventLoop
|
||||||
|
|
||||||
|
|
||||||
class FakeDevice(BECDevice):
|
class FakeDevice(BECDevice):
|
||||||
@@ -308,3 +316,157 @@ def check_remote_data_size(widget, plot_name, num_elements):
|
|||||||
Used in the qtbot.waitUntil function.
|
Used in the qtbot.waitUntil function.
|
||||||
"""
|
"""
|
||||||
return len(widget.get_all_data()[plot_name]["x"]) == num_elements
|
return len(widget.get_all_data()[plot_name]["x"]) == num_elements
|
||||||
|
|
||||||
|
|
||||||
|
class DummyData:
|
||||||
|
def __init__(self, val, timestamps):
|
||||||
|
self.val = val
|
||||||
|
self.timestamps = timestamps
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
if key == "val":
|
||||||
|
return self.val
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def create_dummy_scan_item():
|
||||||
|
"""
|
||||||
|
Helper to create a dummy scan item with both live_data and metadata/status_message info.
|
||||||
|
"""
|
||||||
|
dummy_live_data = {
|
||||||
|
"samx": {"samx": DummyData(val=[10, 20, 30], timestamps=[100, 200, 300])},
|
||||||
|
"samy": {"samy": DummyData(val=[5, 10, 15], timestamps=[100, 200, 300])},
|
||||||
|
"bpm4i": {"bpm4i": DummyData(val=[5, 6, 7], timestamps=[101, 201, 301])},
|
||||||
|
"async_device": {"async_device": DummyData(val=[1, 2, 3], timestamps=[11, 21, 31])},
|
||||||
|
}
|
||||||
|
dummy_scan = MagicMock()
|
||||||
|
dummy_scan.live_data = dummy_live_data
|
||||||
|
dummy_scan.metadata = {
|
||||||
|
"bec": {
|
||||||
|
"scan_id": "dummy",
|
||||||
|
"scan_report_devices": ["samx"],
|
||||||
|
"readout_priority": {"monitored": ["bpm4i"], "async": ["async_device"]},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dummy_scan.status_message.info = {
|
||||||
|
"readout_priority": {"monitored": ["bpm4i"], "async": ["async_device"]},
|
||||||
|
"scan_report_devices": ["samx"],
|
||||||
|
}
|
||||||
|
return dummy_scan
|
||||||
|
|
||||||
|
|
||||||
|
def inject_scan_history(widget, scan_history_factory, *history_args):
|
||||||
|
"""
|
||||||
|
Helper to inject scan history messages into client history.
|
||||||
|
"""
|
||||||
|
history_msgs = []
|
||||||
|
for scan_id, scan_number in history_args:
|
||||||
|
history_msgs.append(scan_history_factory(scan_id=scan_id, scan_number=scan_number))
|
||||||
|
widget.client.history = ScanHistory(widget.client, False)
|
||||||
|
for msg in history_msgs:
|
||||||
|
widget.client.history._scan_data[msg.scan_id] = msg
|
||||||
|
widget.client.history._scan_ids.append(msg.scan_id)
|
||||||
|
widget.client.queue.scan_storage.current_scan = None
|
||||||
|
return history_msgs
|
||||||
|
|
||||||
|
|
||||||
|
def create_history_file(file_path, data: dict, metadata: dict) -> messages.ScanHistoryMessage:
|
||||||
|
"""
|
||||||
|
Helper to create a history file with the given data.
|
||||||
|
The data should contain readout groups, e.g.
|
||||||
|
{
|
||||||
|
"baseline": {"samx": {"samx": {"value": [1, 2, 3], "timestamp": [100, 200, 300]}},
|
||||||
|
"monitored": {"bpm4i": {"bpm4i": {"value": [5, 6, 7], "timestamp": [101, 201, 301]}}},
|
||||||
|
"async": {"async_device": {"async_device": {"value": [1, 2, 3], "timestamp": [11, 21, 31]}}},
|
||||||
|
}
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
with h5py.File(file_path, "w") as f:
|
||||||
|
_metadata = f.create_group("entry/collection/metadata")
|
||||||
|
_metadata.create_dataset("sample_name", data="test_sample")
|
||||||
|
metadata_bec = f.create_group("entry/collection/metadata/bec")
|
||||||
|
for key, value in metadata.items():
|
||||||
|
if isinstance(value, dict):
|
||||||
|
metadata_bec.create_group(key)
|
||||||
|
for sub_key, sub_value in value.items():
|
||||||
|
if isinstance(sub_value, list):
|
||||||
|
sub_value = json.dumps(sub_value)
|
||||||
|
metadata_bec[key].create_dataset(sub_key, data=sub_value)
|
||||||
|
elif isinstance(sub_value, dict):
|
||||||
|
for sub_sub_key, sub_sub_value in sub_value.items():
|
||||||
|
sub_sub_group = metadata_bec[key].create_group(sub_key)
|
||||||
|
# Handle _StoredDataInfo objects
|
||||||
|
if isinstance(sub_sub_value, _StoredDataInfo):
|
||||||
|
# Store the numeric shape
|
||||||
|
sub_sub_group.create_dataset("shape", data=sub_sub_value.shape)
|
||||||
|
# Store the dtype as a UTF-8 string
|
||||||
|
dt = sub_sub_value.dtype or ""
|
||||||
|
sub_sub_group.create_dataset(
|
||||||
|
"dtype", data=dt, dtype=h5py.string_dtype(encoding="utf-8")
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if isinstance(sub_sub_value, list):
|
||||||
|
json_val = json.dumps(sub_sub_value)
|
||||||
|
sub_sub_group.create_dataset(sub_sub_key, data=json_val)
|
||||||
|
elif isinstance(sub_sub_value, dict):
|
||||||
|
for k2, v2 in sub_sub_value.items():
|
||||||
|
val = json.dumps(v2) if isinstance(v2, list) else v2
|
||||||
|
sub_sub_group.create_dataset(k2, data=val)
|
||||||
|
else:
|
||||||
|
sub_sub_group.create_dataset(sub_sub_key, data=sub_sub_value)
|
||||||
|
else:
|
||||||
|
metadata_bec[key].create_dataset(sub_key, data=sub_value)
|
||||||
|
else:
|
||||||
|
metadata_bec.create_dataset(key, data=value)
|
||||||
|
for group, devices in data.items():
|
||||||
|
readout_group = f.create_group(f"entry/collection/readout_groups/{group}")
|
||||||
|
|
||||||
|
for device, device_data in devices.items():
|
||||||
|
dev_group = f.create_group(f"entry/collection/devices/{device}")
|
||||||
|
for signal, signal_data in device_data.items():
|
||||||
|
signal_group = dev_group.create_group(signal)
|
||||||
|
for signal_key, signal_values in signal_data.items():
|
||||||
|
signal_group.create_dataset(signal_key, data=signal_values)
|
||||||
|
|
||||||
|
readout_group[device] = h5py.SoftLink(f"/entry/collection/devices/{device}")
|
||||||
|
msg = messages.ScanHistoryMessage(
|
||||||
|
scan_id=metadata["scan_id"],
|
||||||
|
scan_name=metadata["scan_name"],
|
||||||
|
exit_status=metadata["exit_status"],
|
||||||
|
file_path=file_path,
|
||||||
|
scan_number=metadata["scan_number"],
|
||||||
|
dataset_number=metadata["dataset_number"],
|
||||||
|
start_time=time.time(),
|
||||||
|
end_time=time.time(),
|
||||||
|
num_points=metadata["num_points"],
|
||||||
|
request_inputs=metadata["request_inputs"],
|
||||||
|
stored_data_info=metadata.get("stored_data_info"),
|
||||||
|
metadata={"scan_report_devices": metadata.get("scan_report_devices")},
|
||||||
|
)
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def create_widget(qtbot, widget, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Create a widget and add it to the qtbot for testing. This is a helper function that
|
||||||
|
should be used in all tests that require a widget to be created.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
qtbot (fixture): pytest-qt fixture
|
||||||
|
widget (QWidget): widget class to be created
|
||||||
|
*args: positional arguments for the widget
|
||||||
|
**kwargs: keyword arguments for the widget
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QWidget: the created widget
|
||||||
|
"""
|
||||||
|
widget = widget(*args, **kwargs)
|
||||||
|
qtbot.addWidget(widget)
|
||||||
|
qtbot.waitExposed(widget)
|
||||||
|
return widget
|
||||||
|
|
||||||
|
|
||||||
|
def process_all_deferred_deletes(qapp):
|
||||||
|
qapp.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||||
|
qapp.processEvents(QEventLoop.ProcessEventsFlag.AllEvents)
|
||||||
|
|||||||
@@ -1 +1,13 @@
|
|||||||
|
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
|
||||||
|
|||||||
@@ -12,17 +12,18 @@ import shiboken6 as shb
|
|||||||
from bec_lib.logger import bec_logger
|
from bec_lib.logger import bec_logger
|
||||||
from bec_lib.utils.import_utils import lazy_import_from
|
from bec_lib.utils.import_utils import lazy_import_from
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, Signal
|
from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, QTimer, 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
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||||
|
from bec_widgets.widgets.containers.dock import BECDock
|
||||||
else:
|
else:
|
||||||
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
|
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
|
||||||
|
|
||||||
@@ -88,8 +89,6 @@ class BECConnector:
|
|||||||
gui_id: str | None = None,
|
gui_id: str | None = None,
|
||||||
object_name: str | None = None,
|
object_name: str | None = None,
|
||||||
root_widget: bool = False,
|
root_widget: bool = False,
|
||||||
rpc_exposed: bool = True,
|
|
||||||
rpc_passthrough_children: bool = True,
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -101,10 +100,6 @@ class BECConnector:
|
|||||||
gui_id(str, optional): The GUI ID.
|
gui_id(str, optional): The GUI ID.
|
||||||
object_name(str, optional): The object name.
|
object_name(str, optional): The object name.
|
||||||
root_widget(bool, optional): If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
|
root_widget(bool, optional): If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
|
||||||
rpc_exposed(bool, optional): If set to False, this instance is excluded from RPC registry broadcast and CLI namespace discovery.
|
|
||||||
rpc_passthrough_children(bool, optional): Only relevant when ``rpc_exposed=False``.
|
|
||||||
If True, RPC-visible children rebind to the next visible ancestor.
|
|
||||||
If False (default), children stay hidden behind this widget.
|
|
||||||
**kwargs:
|
**kwargs:
|
||||||
"""
|
"""
|
||||||
# Extract object_name from kwargs to not pass it to Qt class
|
# Extract object_name from kwargs to not pass it to Qt class
|
||||||
@@ -133,13 +128,8 @@ class BECConnector:
|
|||||||
# the function depends on BECClient, and BECDispatcher
|
# the function depends on BECClient, and BECDispatcher
|
||||||
@SafeSlot()
|
@SafeSlot()
|
||||||
def terminate(client=self.client, dispatcher=self.bec_dispatcher):
|
def terminate(client=self.client, dispatcher=self.bec_dispatcher):
|
||||||
app = QApplication.instance()
|
|
||||||
gui_server = getattr(app, "gui_server", None)
|
|
||||||
if gui_server and hasattr(gui_server, "shutdown"):
|
|
||||||
gui_server.shutdown()
|
|
||||||
logger.info("Disconnecting", repr(dispatcher))
|
logger.info("Disconnecting", repr(dispatcher))
|
||||||
dispatcher.disconnect_all()
|
dispatcher.disconnect_all()
|
||||||
dispatcher.stop_cli_server()
|
|
||||||
|
|
||||||
try: # shutdown ophyd threads if any
|
try: # shutdown ophyd threads if any
|
||||||
from ophyd._pyepics_shim import _dispatcher
|
from ophyd._pyepics_shim import _dispatcher
|
||||||
@@ -167,7 +157,7 @@ class BECConnector:
|
|||||||
)
|
)
|
||||||
self.config = ConnectionConfig(widget_class=self.__class__.__name__)
|
self.config = ConnectionConfig(widget_class=self.__class__.__name__)
|
||||||
|
|
||||||
# If the gui_id is passed, it should be respected. However, this should be revisited since
|
# If the gui_id is passed, it should be respected. However, this should be revisted since
|
||||||
# the gui_id has to be unique, and may no longer be.
|
# the gui_id has to be unique, and may no longer be.
|
||||||
if gui_id:
|
if gui_id:
|
||||||
self.config.gui_id = gui_id
|
self.config.gui_id = gui_id
|
||||||
@@ -195,11 +185,6 @@ class BECConnector:
|
|||||||
|
|
||||||
# If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
|
# If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
|
||||||
self.root_widget = root_widget
|
self.root_widget = root_widget
|
||||||
# If set to False, this instance is not exposed through RPC at all.
|
|
||||||
self.rpc_exposed = bool(rpc_exposed)
|
|
||||||
# If True on a hidden parent (rpc_exposed=False), children can bubble up to
|
|
||||||
# the next visible RPC ancestor.
|
|
||||||
self.rpc_passthrough_children = bool(rpc_passthrough_children)
|
|
||||||
|
|
||||||
self._update_object_name()
|
self._update_object_name()
|
||||||
|
|
||||||
@@ -208,41 +193,11 @@ class BECConnector:
|
|||||||
try:
|
try:
|
||||||
if self.root_widget:
|
if self.root_widget:
|
||||||
return None
|
return None
|
||||||
connector_parent = self._get_rpc_parent_ancestor()
|
connector_parent = WidgetHierarchy._get_becwidget_ancestor(self)
|
||||||
return connector_parent.gui_id if connector_parent else None
|
return connector_parent.gui_id if connector_parent else None
|
||||||
except:
|
except:
|
||||||
logger.error(f"Error getting parent_id for {self.__class__.__name__}")
|
logger.error(f"Error getting parent_id for {self.__class__.__name__}")
|
||||||
|
|
||||||
def _get_rpc_parent_ancestor(self) -> BECConnector | None:
|
|
||||||
"""
|
|
||||||
Find the nearest ancestor that is RPC-addressable.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- If an ancestor has ``rpc_exposed=False``, it is an explicit visibility
|
|
||||||
boundary unless ``rpc_passthrough_children=True``.
|
|
||||||
- If an ancestor has ``RPC=False`` (but remains rpc_exposed), it is treated
|
|
||||||
as structural and children continue to the next ancestor.
|
|
||||||
- Lookup always happens through ``WidgetHierarchy.get_becwidget_ancestor``
|
|
||||||
so plain ``QWidget`` nodes between connectors are ignored.
|
|
||||||
"""
|
|
||||||
current = self
|
|
||||||
while True:
|
|
||||||
parent = WidgetHierarchy.get_becwidget_ancestor(current)
|
|
||||||
if parent is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not getattr(parent, "rpc_exposed", True):
|
|
||||||
if getattr(parent, "rpc_passthrough_children", False):
|
|
||||||
current = parent
|
|
||||||
continue
|
|
||||||
return parent
|
|
||||||
|
|
||||||
if getattr(parent, "RPC", True):
|
|
||||||
return parent
|
|
||||||
|
|
||||||
current = parent
|
|
||||||
return None
|
|
||||||
|
|
||||||
def change_object_name(self, name: str) -> None:
|
def change_object_name(self, name: str) -> None:
|
||||||
"""
|
"""
|
||||||
Change the object name of the widget. Unregister old name and register the new one.
|
Change the object name of the widget. Unregister old name and register the new one.
|
||||||
@@ -261,9 +216,8 @@ class BECConnector:
|
|||||||
"""
|
"""
|
||||||
# 1) Enforce unique objectName among siblings with the same BECConnector parent
|
# 1) Enforce unique objectName among siblings with the same BECConnector parent
|
||||||
self._enforce_unique_sibling_name()
|
self._enforce_unique_sibling_name()
|
||||||
# 2) Register the object for RPC unless instance-level exposure is disabled.
|
# 2) Register the object for RPC
|
||||||
if getattr(self, "rpc_exposed", True):
|
self.rpc_register.add_rpc(self)
|
||||||
self.rpc_register.add_rpc(self)
|
|
||||||
try:
|
try:
|
||||||
self.name_established.emit(self.object_name)
|
self.name_established.emit(self.object_name)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
@@ -281,7 +235,7 @@ class BECConnector:
|
|||||||
if not shb.isValid(self):
|
if not shb.isValid(self):
|
||||||
return
|
return
|
||||||
|
|
||||||
parent_bec = WidgetHierarchy.get_becwidget_ancestor(self)
|
parent_bec = WidgetHierarchy._get_becwidget_ancestor(self)
|
||||||
|
|
||||||
if parent_bec:
|
if parent_bec:
|
||||||
# We have a parent => only compare with siblings under that parent
|
# We have a parent => only compare with siblings under that parent
|
||||||
@@ -291,7 +245,7 @@ class BECConnector:
|
|||||||
# Use RPCRegister to avoid QApplication.allWidgets() during event processing.
|
# Use RPCRegister to avoid QApplication.allWidgets() during event processing.
|
||||||
connections = self.rpc_register.list_all_connections().values()
|
connections = self.rpc_register.list_all_connections().values()
|
||||||
all_bec = [w for w in connections if isinstance(w, BECConnector) and shb.isValid(w)]
|
all_bec = [w for w in connections if isinstance(w, BECConnector) and shb.isValid(w)]
|
||||||
siblings = [w for w in all_bec if WidgetHierarchy.get_becwidget_ancestor(w) is None]
|
siblings = [w for w in all_bec if WidgetHierarchy._get_becwidget_ancestor(w) is None]
|
||||||
|
|
||||||
# Collect used names among siblings
|
# Collect used names among siblings
|
||||||
used_names = {sib.objectName() for sib in siblings if sib is not self}
|
used_names = {sib.objectName() for sib in siblings if sib is not self}
|
||||||
@@ -319,8 +273,6 @@ class BECConnector:
|
|||||||
Args:
|
Args:
|
||||||
name (str): The new object name.
|
name (str): The new object name.
|
||||||
"""
|
"""
|
||||||
# sanitize before setting to avoid issues with Qt object names and RPC namespaces
|
|
||||||
name = sanitize_namespace(name)
|
|
||||||
super().setObjectName(name)
|
super().setObjectName(name)
|
||||||
self.object_name = name
|
self.object_name = name
|
||||||
if self.rpc_register.object_is_registered(self):
|
if self.rpc_register.object_is_registered(self):
|
||||||
@@ -399,7 +351,7 @@ class BECConnector:
|
|||||||
"""
|
"""
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
# FIXME some thoughts are required to decide how this should work with rpc registry
|
# FIXME some thoughts are required to decide how thhis should work with rpc registry
|
||||||
def apply_config(self, config: dict, generate_new_id: bool = True) -> None:
|
def apply_config(self, config: dict, generate_new_id: bool = True) -> None:
|
||||||
"""
|
"""
|
||||||
Apply the configuration to the widget.
|
Apply the configuration to the widget.
|
||||||
@@ -417,7 +369,7 @@ class BECConnector:
|
|||||||
else:
|
else:
|
||||||
self.gui_id = self.config.gui_id
|
self.gui_id = self.config.gui_id
|
||||||
|
|
||||||
# FIXME some thoughts are required to decide how this should work with rpc registry
|
# FIXME some thoughts are required to decide how thhis should work with rpc registry
|
||||||
def load_config(self, path: str | None = None, gui: bool = False):
|
def load_config(self, path: str | None = None, gui: bool = False):
|
||||||
"""
|
"""
|
||||||
Load the configuration of the widget from YAML.
|
Load the configuration of the widget from YAML.
|
||||||
|
|||||||
@@ -175,15 +175,12 @@ class BECDispatcher:
|
|||||||
cb_info (dict | None): A dictionary containing information about the callback. Defaults to None.
|
cb_info (dict | None): A dictionary containing information about the callback. Defaults to None.
|
||||||
"""
|
"""
|
||||||
qt_slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info)
|
qt_slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info)
|
||||||
if not self.client.connector.any_stream_is_registered(topics, qt_slot):
|
if qt_slot not in self._registered_slots:
|
||||||
if qt_slot not in self._registered_slots:
|
self._registered_slots[qt_slot] = qt_slot
|
||||||
self._registered_slots[qt_slot] = qt_slot
|
qt_slot = self._registered_slots[qt_slot]
|
||||||
qt_slot = self._registered_slots[qt_slot]
|
self.client.connector.register(topics, cb=qt_slot, **kwargs)
|
||||||
self.client.connector.register(topics, cb=qt_slot, **kwargs)
|
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
qt_slot.topics.update(set(topics_str))
|
||||||
qt_slot.topics.update(set(topics_str))
|
|
||||||
else:
|
|
||||||
logger.warning(f"Attempted to create duplicate stream subscription for {topics=}")
|
|
||||||
|
|
||||||
def disconnect_slot(
|
def disconnect_slot(
|
||||||
self, slot: Callable, topics: EndpointInfo | str | list[EndpointInfo] | list[str]
|
self, slot: Callable, topics: EndpointInfo | str | list[EndpointInfo] | list[str]
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
"""
|
|
||||||
Login dialog for user authentication.
|
|
||||||
The Login Widget is styled in a Material Design style and emits
|
|
||||||
the entered credentials through a signal for further processing.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from qtpy.QtCore import Qt, Signal
|
|
||||||
from qtpy.QtWidgets import QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget
|
|
||||||
|
|
||||||
|
|
||||||
class BECLogin(QWidget):
|
|
||||||
"""Login dialog for user authentication in Material Design style."""
|
|
||||||
|
|
||||||
credentials_entered = Signal(str, str)
|
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
|
||||||
super().__init__(parent=parent)
|
|
||||||
# Only displayed if this widget as standalone widget, and not embedded in another widget
|
|
||||||
self.setWindowTitle("Login")
|
|
||||||
|
|
||||||
title = QLabel("Sign in", parent=self)
|
|
||||||
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
||||||
title.setStyleSheet("""
|
|
||||||
#QLabel
|
|
||||||
{
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
|
|
||||||
self.username = QLineEdit(parent=self)
|
|
||||||
self.username.setPlaceholderText("Username")
|
|
||||||
|
|
||||||
self.password = QLineEdit(parent=self)
|
|
||||||
self.password.setPlaceholderText("Password")
|
|
||||||
self.password.setEchoMode(QLineEdit.EchoMode.Password)
|
|
||||||
|
|
||||||
self.ok_btn = QPushButton("Sign in", parent=self)
|
|
||||||
self.ok_btn.setDefault(True)
|
|
||||||
self.ok_btn.clicked.connect(self._emit_credentials)
|
|
||||||
# If the user presses Enter in the password field, trigger the OK button click
|
|
||||||
self.password.returnPressed.connect(self.ok_btn.click)
|
|
||||||
|
|
||||||
# Build Layout
|
|
||||||
layout = QVBoxLayout(self)
|
|
||||||
layout.setContentsMargins(32, 32, 32, 32)
|
|
||||||
layout.setSpacing(16)
|
|
||||||
|
|
||||||
layout.addWidget(title)
|
|
||||||
layout.addSpacing(8)
|
|
||||||
layout.addWidget(self.username)
|
|
||||||
layout.addWidget(self.password)
|
|
||||||
layout.addSpacing(12)
|
|
||||||
layout.addWidget(self.ok_btn)
|
|
||||||
|
|
||||||
self.username.setFocus()
|
|
||||||
|
|
||||||
self.setStyleSheet("""
|
|
||||||
QLineEdit {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
|
|
||||||
def _clear_password(self):
|
|
||||||
"""Clear the password field."""
|
|
||||||
self.password.clear()
|
|
||||||
|
|
||||||
def _emit_credentials(self):
|
|
||||||
"""Emit credentials and clear the password field."""
|
|
||||||
self.credentials_entered.emit(self.username.text().strip(), self.password.text())
|
|
||||||
self._clear_password()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from bec_qthemes import apply_theme
|
|
||||||
from qtpy.QtWidgets import QApplication
|
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
|
||||||
apply_theme("light")
|
|
||||||
|
|
||||||
dialog = BECLogin()
|
|
||||||
|
|
||||||
dialog.credentials_entered.connect(lambda u, p: print(f"Username: {u}, Password: {p}"))
|
|
||||||
dialog.show()
|
|
||||||
sys.exit(app.exec_())
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class WidgetContainerUtils:
|
|||||||
if list_of_names is None:
|
if list_of_names is None:
|
||||||
list_of_names = []
|
list_of_names = []
|
||||||
ii = 0
|
ii = 0
|
||||||
while ii < 1000: # 1000 is arbitrary!
|
while ii < 1000: # 1000 is arbritrary!
|
||||||
name_candidate = f"{name}_{ii}"
|
name_candidate = f"{name}_{ii}"
|
||||||
if name_candidate not in list_of_names:
|
if name_candidate not in list_of_names:
|
||||||
return name_candidate
|
return name_candidate
|
||||||
|
|||||||
@@ -106,9 +106,7 @@ class TypedForm(BECWidget, QWidget):
|
|||||||
|
|
||||||
def _add_griditem(self, item: FormItemSpec, row: int):
|
def _add_griditem(self, item: FormItemSpec, row: int):
|
||||||
grid = self._form_grid.layout()
|
grid = self._form_grid.layout()
|
||||||
# Use title from FieldInfo if available, otherwise use the property name
|
label = QLabel(parent=self._form_grid, text=item.name)
|
||||||
label_text = item.info.title if item.info.title else item.name
|
|
||||||
label = QLabel(parent=self._form_grid, text=label_text)
|
|
||||||
label.setProperty("_model_field_name", item.name)
|
label.setProperty("_model_field_name", item.name)
|
||||||
label.setToolTip(item.info.description or item.name)
|
label.setToolTip(item.info.description or item.name)
|
||||||
grid.addWidget(label, row, 0)
|
grid.addWidget(label, row, 0)
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class FormItemSpec(BaseModel):
|
|||||||
"""
|
"""
|
||||||
The specification for an item in a dynamically generated form. Uses a pydantic FieldInfo
|
The specification for an item in a dynamically generated form. Uses a pydantic FieldInfo
|
||||||
to store most annotation info, since one of the main purposes is to store data for
|
to store most annotation info, since one of the main purposes is to store data for
|
||||||
forms generated from pydantic models, but can also be composed from other sources or by hand.
|
forms genrated from pydantic models, but can also be composed from other sources or by hand.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
@@ -192,7 +192,7 @@ class DynamicFormItem(QWidget):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
def _add_main_widget(self) -> None:
|
def _add_main_widget(self) -> None:
|
||||||
self._main_widget: QWidget
|
self._main_widget: QWidget
|
||||||
"""Add the main data entry widget to self._main_widget and apply any
|
"""Add the main data entry widget to self._main_widget and appply any
|
||||||
constraints from the field info"""
|
constraints from the field info"""
|
||||||
|
|
||||||
@SafeSlot()
|
@SafeSlot()
|
||||||
@@ -231,8 +231,6 @@ class StrFormItem(DynamicFormItem):
|
|||||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||||
super().__init__(parent=parent, spec=spec)
|
super().__init__(parent=parent, spec=spec)
|
||||||
self._main_widget.textChanged.connect(self._value_changed)
|
self._main_widget.textChanged.connect(self._value_changed)
|
||||||
if spec.info.description:
|
|
||||||
self._main_widget.setPlaceholderText(spec.info.description)
|
|
||||||
|
|
||||||
def _add_main_widget(self) -> None:
|
def _add_main_widget(self) -> None:
|
||||||
self._main_widget = QLineEdit()
|
self._main_widget = QLineEdit()
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
"""Module providing fuzzy search utilities for the BEC widgets."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from thefuzz import fuzz
|
|
||||||
|
|
||||||
FUZZY_SEARCH_THRESHOLD = 80
|
|
||||||
|
|
||||||
|
|
||||||
def is_match(
|
|
||||||
text: str, row_data: dict[str, Any], relevant_keys: list[str], enable_fuzzy: bool
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Check if the text matches any of the relevant keys in the row data.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text (str): The text to search for.
|
|
||||||
row_data (dict[str, Any]): The row data to search in.
|
|
||||||
relevant_keys (list[str]): The keys to consider for searching.
|
|
||||||
enable_fuzzy (bool): Whether to use fuzzy matching.
|
|
||||||
Returns:
|
|
||||||
bool: True if a match is found, False otherwise.
|
|
||||||
"""
|
|
||||||
for key in relevant_keys:
|
|
||||||
data = str(row_data.get(key, "") or "")
|
|
||||||
if enable_fuzzy:
|
|
||||||
match_ratio = fuzz.partial_ratio(text.lower(), data.lower())
|
|
||||||
if match_ratio >= FUZZY_SEARCH_THRESHOLD:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
if text.lower() in data.lower():
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import sys
|
import sys
|
||||||
import weakref
|
import weakref
|
||||||
from typing import Callable, Dict, List, Literal, TypedDict
|
from typing import Callable, Dict, List, TypedDict
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import louie
|
import louie
|
||||||
@@ -12,18 +12,15 @@ from bec_lib.logger import bec_logger
|
|||||||
from bec_qthemes import material_icon
|
from bec_qthemes import material_icon
|
||||||
from louie import saferef
|
from louie import saferef
|
||||||
from qtpy.QtCore import QEvent, QObject, QRect, Qt, Signal
|
from qtpy.QtCore import QEvent, QObject, QRect, Qt, Signal
|
||||||
from qtpy.QtGui import QAction, QColor, QKeySequence, QPainter, QPen, QShortcut
|
from qtpy.QtGui import QAction, QColor, QPainter, QPen
|
||||||
from qtpy.QtWidgets import (
|
from qtpy.QtWidgets import (
|
||||||
QAbstractItemView,
|
|
||||||
QApplication,
|
QApplication,
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
QMenu,
|
|
||||||
QMenuBar,
|
QMenuBar,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QTableWidgetItem,
|
|
||||||
QToolBar,
|
QToolBar,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
@@ -43,9 +40,9 @@ class TourStep(TypedDict):
|
|||||||
widget_ref: (
|
widget_ref: (
|
||||||
louie.saferef.BoundMethodWeakref
|
louie.saferef.BoundMethodWeakref
|
||||||
| weakref.ReferenceType[
|
| weakref.ReferenceType[
|
||||||
QWidget | QAction | QRect | Callable[[], tuple[QWidget | QAction | QRect, str | None]]
|
QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]]
|
||||||
]
|
]
|
||||||
| Callable[[], tuple[QWidget | QAction | QRect, str | None]]
|
| Callable[[], tuple[QWidget | QAction, str | None]]
|
||||||
| None
|
| None
|
||||||
)
|
)
|
||||||
text: str
|
text: str
|
||||||
@@ -67,13 +64,15 @@ class TutorialOverlay(QWidget):
|
|||||||
box = QFrame(self)
|
box = QFrame(self)
|
||||||
app = QApplication.instance()
|
app = QApplication.instance()
|
||||||
bg_color = app.palette().window().color()
|
bg_color = app.palette().window().color()
|
||||||
box.setStyleSheet(f"""
|
box.setStyleSheet(
|
||||||
|
f"""
|
||||||
QFrame {{
|
QFrame {{
|
||||||
background-color: {bg_color.name()};
|
background-color: {bg_color.name()};
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}}
|
}}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
layout = QVBoxLayout(box)
|
layout = QVBoxLayout(box)
|
||||||
|
|
||||||
# Top layout with close button (left) and step indicator (right)
|
# Top layout with close button (left) and step indicator (right)
|
||||||
@@ -104,12 +103,10 @@ class TutorialOverlay(QWidget):
|
|||||||
# Back button with material icon
|
# Back button with material icon
|
||||||
self.back_btn = QPushButton("Back")
|
self.back_btn = QPushButton("Back")
|
||||||
self.back_btn.setIcon(material_icon("arrow_back"))
|
self.back_btn.setIcon(material_icon("arrow_back"))
|
||||||
self.back_btn.setToolTip("Press Backspace to go back")
|
|
||||||
|
|
||||||
# Next button with material icon
|
# Next button with material icon
|
||||||
self.next_btn = QPushButton("Next")
|
self.next_btn = QPushButton("Next")
|
||||||
self.next_btn.setIcon(material_icon("arrow_forward"))
|
self.next_btn.setIcon(material_icon("arrow_forward"))
|
||||||
self.next_btn.setToolTip("Press Enter to continue")
|
|
||||||
|
|
||||||
btn_layout.addStretch()
|
btn_layout.addStretch()
|
||||||
btn_layout.addWidget(self.back_btn)
|
btn_layout.addWidget(self.back_btn)
|
||||||
@@ -118,15 +115,6 @@ class TutorialOverlay(QWidget):
|
|||||||
layout.addLayout(top_layout)
|
layout.addLayout(top_layout)
|
||||||
layout.addWidget(self.label)
|
layout.addWidget(self.label)
|
||||||
layout.addLayout(btn_layout)
|
layout.addLayout(btn_layout)
|
||||||
|
|
||||||
# Escape closes the tour
|
|
||||||
QShortcut(QKeySequence(Qt.Key.Key_Escape), self, activated=self.close_btn.click)
|
|
||||||
# Enter and Return activates the next button
|
|
||||||
QShortcut(QKeySequence(Qt.Key.Key_Return), self, activated=self.next_btn.click)
|
|
||||||
QShortcut(QKeySequence(Qt.Key.Key_Enter), self, activated=self.next_btn.click)
|
|
||||||
# Map Backspace to the back button
|
|
||||||
QShortcut(QKeySequence(Qt.Key.Key_Backspace), self, activated=self.back_btn.click)
|
|
||||||
|
|
||||||
return box
|
return box
|
||||||
|
|
||||||
def paintEvent(self, event): # pylint: disable=unused-argument
|
def paintEvent(self, event): # pylint: disable=unused-argument
|
||||||
@@ -235,9 +223,6 @@ class TutorialOverlay(QWidget):
|
|||||||
self.message_box.show()
|
self.message_box.show()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
# Update the focus policy of the buttons
|
|
||||||
self.back_btn.setEnabled(current_step > 1)
|
|
||||||
|
|
||||||
def eventFilter(self, obj, event):
|
def eventFilter(self, obj, event):
|
||||||
if event.type() == QEvent.Type.Resize:
|
if event.type() == QEvent.Type.Resize:
|
||||||
self.setGeometry(obj.rect())
|
self.setGeometry(obj.rect())
|
||||||
@@ -277,9 +262,7 @@ class GuidedTour(QObject):
|
|||||||
def register_widget(
|
def register_widget(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
widget: (
|
widget: QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]],
|
||||||
QWidget | QAction | QRect | Callable[[], tuple[QWidget | QAction | QRect, str | None]]
|
|
||||||
),
|
|
||||||
text: str = "",
|
text: str = "",
|
||||||
title: str = "",
|
title: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -287,7 +270,7 @@ class GuidedTour(QObject):
|
|||||||
Register a widget with help text for tours.
|
Register a widget with help text for tours.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
widget (QWidget | QAction | QRect | Callable[[], tuple[QWidget | QAction | QRect, str | None]]): The target widget or a callable that returns the widget and its help text.
|
widget (QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]]): The target widget or a callable that returns the widget and its help text.
|
||||||
text (str): The help text for the widget. This will be shown during the tour.
|
text (str): The help text for the widget. This will be shown during the tour.
|
||||||
title (str, optional): A title for the widget (defaults to its class name or action text).
|
title (str, optional): A title for the widget (defaults to its class name or action text).
|
||||||
|
|
||||||
@@ -310,9 +293,6 @@ class GuidedTour(QObject):
|
|||||||
|
|
||||||
widget_ref = _resolve_toolbar_button
|
widget_ref = _resolve_toolbar_button
|
||||||
default_title = getattr(widget, "tooltip", "Toolbar Menu")
|
default_title = getattr(widget, "tooltip", "Toolbar Menu")
|
||||||
elif isinstance(widget, QRect):
|
|
||||||
widget_ref = widget
|
|
||||||
default_title = "Area"
|
|
||||||
else:
|
else:
|
||||||
widget_ref = saferef.safe_ref(widget)
|
widget_ref = saferef.safe_ref(widget)
|
||||||
default_title = widget.__class__.__name__ if hasattr(widget, "__class__") else "Widget"
|
default_title = widget.__class__.__name__ if hasattr(widget, "__class__") else "Widget"
|
||||||
@@ -347,14 +327,11 @@ class GuidedTour(QObject):
|
|||||||
if mb and mb not in menubars:
|
if mb and mb not in menubars:
|
||||||
menubars.append(mb)
|
menubars.append(mb)
|
||||||
menubars += [mb for mb in mw.findChildren(QMenuBar) if mb not in menubars]
|
menubars += [mb for mb in mw.findChildren(QMenuBar) if mb not in menubars]
|
||||||
menubars += [mb for mb in mw.findChildren(QMenu) if mb not in menubars]
|
|
||||||
|
|
||||||
for mb in menubars:
|
for mb in menubars:
|
||||||
if action in mb.actions():
|
if action in mb.actions():
|
||||||
ar = mb.actionGeometry(action)
|
ar = mb.actionGeometry(action)
|
||||||
top_left = mb.mapTo(mw, ar.topLeft())
|
top_left = mb.mapTo(mw, ar.topLeft())
|
||||||
return QRect(top_left, ar.size())
|
return QRect(top_left, ar.size())
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def unregister_widget(self, step_id: str) -> bool:
|
def unregister_widget(self, step_id: str) -> bool:
|
||||||
@@ -475,9 +452,9 @@ class GuidedTour(QObject):
|
|||||||
|
|
||||||
if self._current_index > 0:
|
if self._current_index > 0:
|
||||||
self._current_index -= 1
|
self._current_index -= 1
|
||||||
self._show_current_step(direction="prev")
|
self._show_current_step()
|
||||||
|
|
||||||
def _show_current_step(self, direction: Literal["next"] | Literal["prev"] = "next"):
|
def _show_current_step(self):
|
||||||
"""Display the current step."""
|
"""Display the current step."""
|
||||||
if not self._active or not self.overlay:
|
if not self._active or not self.overlay:
|
||||||
return
|
return
|
||||||
@@ -487,9 +464,7 @@ class GuidedTour(QObject):
|
|||||||
|
|
||||||
target, step_text = self._resolve_step_target(step)
|
target, step_text = self._resolve_step_target(step)
|
||||||
if target is None:
|
if target is None:
|
||||||
self._advance_past_invalid_step(
|
self._advance_past_invalid_step(step_title, reason="Step target no longer exists.")
|
||||||
step_title, reason="Step target no longer exists.", direction=direction
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
main_window = self.main_window
|
main_window = self.main_window
|
||||||
@@ -498,9 +473,7 @@ class GuidedTour(QObject):
|
|||||||
self.stop_tour()
|
self.stop_tour()
|
||||||
return
|
return
|
||||||
|
|
||||||
highlight_rect = self._get_highlight_rect(
|
highlight_rect = self._get_highlight_rect(main_window, target, step_title)
|
||||||
main_window, target, step_title, direction=direction
|
|
||||||
)
|
|
||||||
if highlight_rect is None:
|
if highlight_rect is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -510,6 +483,9 @@ class GuidedTour(QObject):
|
|||||||
|
|
||||||
self.overlay.show_step(highlight_rect, step_title, step_text, current_step, total_steps)
|
self.overlay.show_step(highlight_rect, step_title, step_text, current_step, total_steps)
|
||||||
|
|
||||||
|
# Update button states
|
||||||
|
self.overlay.back_btn.setEnabled(self._current_index > 0)
|
||||||
|
|
||||||
# Update next button text and state
|
# Update next button text and state
|
||||||
is_last_step = self._current_index >= len(self._tour_steps) - 1
|
is_last_step = self._current_index >= len(self._tour_steps) - 1
|
||||||
if is_last_step:
|
if is_last_step:
|
||||||
@@ -523,7 +499,7 @@ class GuidedTour(QObject):
|
|||||||
|
|
||||||
self.step_changed.emit(self._current_index + 1, len(self._tour_steps))
|
self.step_changed.emit(self._current_index + 1, len(self._tour_steps))
|
||||||
|
|
||||||
def _resolve_step_target(self, step: TourStep) -> tuple[QWidget | QAction | QRect | None, str]:
|
def _resolve_step_target(self, step: TourStep) -> tuple[QWidget | QAction | None, str]:
|
||||||
"""
|
"""
|
||||||
Resolve the target widget/action for the given step.
|
Resolve the target widget/action for the given step.
|
||||||
|
|
||||||
@@ -531,7 +507,7 @@ class GuidedTour(QObject):
|
|||||||
step(TourStep): The tour step to resolve.
|
step(TourStep): The tour step to resolve.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple[QWidget | QAction | QRect | None, str]: The resolved target, optional QRect, and the step text.
|
tuple[QWidget | QAction | None, str]: The resolved target and the step text.
|
||||||
"""
|
"""
|
||||||
widget_ref = step.get("widget_ref")
|
widget_ref = step.get("widget_ref")
|
||||||
step_text = step.get("text", "")
|
step_text = step.get("text", "")
|
||||||
@@ -544,7 +520,7 @@ class GuidedTour(QObject):
|
|||||||
if target is None:
|
if target is None:
|
||||||
return None, step_text
|
return None, step_text
|
||||||
|
|
||||||
if callable(target) and not isinstance(target, (QWidget, QAction, QRect)):
|
if callable(target) and not isinstance(target, (QWidget, QAction)):
|
||||||
result = target()
|
result = target()
|
||||||
if isinstance(result, tuple):
|
if isinstance(result, tuple):
|
||||||
target, alt_text = result
|
target, alt_text = result
|
||||||
@@ -556,11 +532,7 @@ class GuidedTour(QObject):
|
|||||||
return target, step_text
|
return target, step_text
|
||||||
|
|
||||||
def _get_highlight_rect(
|
def _get_highlight_rect(
|
||||||
self,
|
self, main_window: QWidget, target: QWidget | QAction, step_title: str
|
||||||
main_window: QWidget,
|
|
||||||
target: QWidget | QAction | QRect,
|
|
||||||
step_title: str,
|
|
||||||
direction: Literal["next"] | Literal["prev"] = "next",
|
|
||||||
) -> QRect | None:
|
) -> QRect | None:
|
||||||
"""
|
"""
|
||||||
Get the QRect in main_window coordinates to highlight for the given target.
|
Get the QRect in main_window coordinates to highlight for the given target.
|
||||||
@@ -573,15 +545,12 @@ class GuidedTour(QObject):
|
|||||||
Returns:
|
Returns:
|
||||||
QRect | None: The rectangle to highlight, or None if not found/visible.
|
QRect | None: The rectangle to highlight, or None if not found/visible.
|
||||||
"""
|
"""
|
||||||
if isinstance(target, QRect):
|
|
||||||
return target
|
|
||||||
if isinstance(target, QAction):
|
if isinstance(target, QAction):
|
||||||
rect = self._action_highlight_rect(target)
|
rect = self._action_highlight_rect(target)
|
||||||
if rect is None:
|
if rect is None:
|
||||||
self._advance_past_invalid_step(
|
self._advance_past_invalid_step(
|
||||||
step_title,
|
step_title,
|
||||||
reason=f"Could not find visible widget or menu for QAction {target.text()!r}.",
|
reason=f"Could not find visible widget or menu for QAction {target.text()!r}.",
|
||||||
direction=direction,
|
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
return rect
|
return rect
|
||||||
@@ -590,60 +559,28 @@ class GuidedTour(QObject):
|
|||||||
if self._visible_check:
|
if self._visible_check:
|
||||||
if not target.isVisible():
|
if not target.isVisible():
|
||||||
self._advance_past_invalid_step(
|
self._advance_past_invalid_step(
|
||||||
step_title, reason=f"Widget {target!r} is not visible.", direction=direction
|
step_title, reason=f"Widget {target!r} is not visible."
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
rect = target.rect()
|
rect = target.rect()
|
||||||
top_left = target.mapTo(main_window, rect.topLeft())
|
top_left = target.mapTo(main_window, rect.topLeft())
|
||||||
return QRect(top_left, rect.size())
|
return QRect(top_left, rect.size())
|
||||||
|
|
||||||
if isinstance(target, QTableWidgetItem):
|
|
||||||
# NOTE: On header items (which are also QTableWidgetItems), this does not work,
|
|
||||||
# Header items are just used as data containers by Qt, thus, we have to directly
|
|
||||||
# pass the QRect through the method (+ make sure the appropriate header section
|
|
||||||
# is visible). This can be handled in the callable method.)
|
|
||||||
table = target.tableWidget()
|
|
||||||
|
|
||||||
if self._visible_check:
|
|
||||||
if not table.isVisible():
|
|
||||||
self._advance_past_invalid_step(
|
|
||||||
step_title,
|
|
||||||
reason=f"Table widget {table!r} is not visible.",
|
|
||||||
direction=direction,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Table item
|
|
||||||
if table.item(target.row(), target.column()) == target:
|
|
||||||
table.scrollToItem(target, QAbstractItemView.ScrollHint.PositionAtCenter)
|
|
||||||
rect = table.visualItemRect(target)
|
|
||||||
top_left = table.viewport().mapTo(main_window, rect.topLeft())
|
|
||||||
return QRect(top_left, rect.size())
|
|
||||||
|
|
||||||
self._advance_past_invalid_step(
|
self._advance_past_invalid_step(
|
||||||
step_title, reason=f"Unsupported step target type: {type(target)}", direction=direction
|
step_title, reason=f"Unsupported step target type: {type(target)}"
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _advance_past_invalid_step(
|
def _advance_past_invalid_step(self, step_title: str, *, reason: str):
|
||||||
self, step_title: str, *, reason: str, direction: Literal["next"] | Literal["prev"] = "next"
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Skip the current step (or stop the tour) when the target cannot be visualised.
|
Skip the current step (or stop the tour) when the target cannot be visualised.
|
||||||
"""
|
"""
|
||||||
logger.warning(f"{reason} Skipping step {step_title!r}.")
|
logger.warning("%s Skipping step %r.", reason, step_title)
|
||||||
if direction == "next":
|
if self._current_index < len(self._tour_steps) - 1:
|
||||||
if self._current_index < len(self._tour_steps) - 1:
|
self._current_index += 1
|
||||||
self._current_index += 1
|
self._show_current_step()
|
||||||
self._show_current_step()
|
else:
|
||||||
else:
|
self.stop_tour()
|
||||||
self.stop_tour()
|
|
||||||
elif direction == "prev":
|
|
||||||
if self._current_index > 0:
|
|
||||||
self._current_index -= 1
|
|
||||||
self._show_current_step(direction="prev")
|
|
||||||
else:
|
|
||||||
self.stop_tour()
|
|
||||||
|
|
||||||
def get_registered_widgets(self) -> Dict[str, TourStep]:
|
def get_registered_widgets(self) -> Dict[str, TourStep]:
|
||||||
"""Get all registered widgets."""
|
"""Get all registered widgets."""
|
||||||
@@ -726,33 +663,8 @@ class MainWindow(QMainWindow): # pragma: no cover
|
|||||||
title="Tools Menu",
|
title="Tools Menu",
|
||||||
)
|
)
|
||||||
|
|
||||||
sub_menu_action = self.tools_menu_actions["notes"].action
|
|
||||||
|
|
||||||
def get_sub_menu_action():
|
|
||||||
# open the tools menu
|
|
||||||
menu_button = self.tools_menu_action._button_ref()
|
|
||||||
if menu_button:
|
|
||||||
menu_button.showMenu()
|
|
||||||
|
|
||||||
return (
|
|
||||||
self.tools_menu_action.actions["notes"].action,
|
|
||||||
"This action allows you to add notes.",
|
|
||||||
)
|
|
||||||
|
|
||||||
sub_menu = self.guided_help.register_widget(
|
|
||||||
widget=get_sub_menu_action,
|
|
||||||
text="This is a sub-action within the tools menu.",
|
|
||||||
title="Add Note Action",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create tour from registered widgets
|
# Create tour from registered widgets
|
||||||
self.tour_step_ids = [
|
self.tour_step_ids = [primary_step, secondary_step, toolbar_action_step, tools_menu_step]
|
||||||
sub_menu,
|
|
||||||
primary_step,
|
|
||||||
secondary_step,
|
|
||||||
toolbar_action_step,
|
|
||||||
tools_menu_step,
|
|
||||||
]
|
|
||||||
widget_ids = self.tour_step_ids
|
widget_ids = self.tour_step_ids
|
||||||
self.guided_help.create_tour(widget_ids)
|
self.guided_help.create_tour(widget_ids)
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ class HelpInspector(BECWidget, QtWidgets.QWidget):
|
|||||||
# TODO check what happens if the HELP Inspector itself is embedded in another BECWidget
|
# TODO check what happens if the HELP Inspector itself is embedded in another BECWidget
|
||||||
# I suppose we would like to get the first ancestor that is a BECWidget, not the topmost one
|
# I suppose we would like to get the first ancestor that is a BECWidget, not the topmost one
|
||||||
if not isinstance(widget, BECWidget):
|
if not isinstance(widget, BECWidget):
|
||||||
widget = WidgetHierarchy.get_becwidget_ancestor(widget)
|
widget = WidgetHierarchy._get_becwidget_ancestor(widget)
|
||||||
if widget:
|
if widget:
|
||||||
if widget is self:
|
if widget is self:
|
||||||
self._toggle_mode(False)
|
self._toggle_mode(False)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class Kind(IFBase):
|
|||||||
"""
|
"""
|
||||||
This is used in the .kind attribute of all OphydObj (Signals, Devices).
|
This is used in the .kind attribute of all OphydObj (Signals, Devices).
|
||||||
|
|
||||||
A Device examines its components' .kind attribute to decide whether to
|
A Device examines its components' .kind atttribute to decide whether to
|
||||||
traverse it in read(), read_configuration(), or neither. Additionally, if
|
traverse it in read(), read_configuration(), or neither. Additionally, if
|
||||||
decides whether to include its name in `hints['fields']`.
|
decides whether to include its name in `hints['fields']`.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class {plugin_name_pascal}Plugin(QDesignerCustomWidgetInterface): # pragma: no
|
|||||||
|
|
||||||
def createWidget(self, parent):
|
def createWidget(self, parent):
|
||||||
if parent is None:
|
if parent is None:
|
||||||
return QWidget()
|
return QWidget()
|
||||||
t = {plugin_name_pascal}(parent)
|
t = {plugin_name_pascal}(parent)
|
||||||
return t
|
return t
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ from dataclasses import dataclass
|
|||||||
from typing import TYPE_CHECKING, Iterable
|
from typing import TYPE_CHECKING, Iterable
|
||||||
|
|
||||||
from bec_lib.plugin_helper import _get_available_plugins
|
from bec_lib.plugin_helper import _get_available_plugins
|
||||||
from qtpy.QtWidgets import QWidget
|
from qtpy.QtWidgets import QGraphicsWidget, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils.bec_connector import BECConnector
|
from bec_widgets.utils import BECConnector
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
@@ -166,17 +166,18 @@ class BECClassContainer:
|
|||||||
return [info.obj for info in self.collection]
|
return [info.obj for info in self.collection]
|
||||||
|
|
||||||
|
|
||||||
def _collect_classes_from_package(repo_name: str, package: str) -> BECClassContainer:
|
def get_custom_classes(repo_name: str) -> BECClassContainer:
|
||||||
"""Collect classes from a package subtree (for example ``widgets`` or ``applications``)."""
|
"""
|
||||||
collection = BECClassContainer()
|
Get all RPC-enabled classes in the specified repository.
|
||||||
try:
|
|
||||||
anchor_module = importlib.import_module(f"{repo_name}.{package}")
|
|
||||||
except ModuleNotFoundError as exc:
|
|
||||||
# Some plugin repositories expose only one subtree. Skip gracefully if it does not exist.
|
|
||||||
if exc.name == f"{repo_name}.{package}":
|
|
||||||
return collection
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_name(str): The name of the repository.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
|
||||||
|
"""
|
||||||
|
collection = BECClassContainer()
|
||||||
|
anchor_module = importlib.import_module(f"{repo_name}.widgets")
|
||||||
directory = os.path.dirname(anchor_module.__file__)
|
directory = os.path.dirname(anchor_module.__file__)
|
||||||
for root, _, files in sorted(os.walk(directory)):
|
for root, _, files in sorted(os.walk(directory)):
|
||||||
for file in files:
|
for file in files:
|
||||||
@@ -184,13 +185,13 @@ def _collect_classes_from_package(repo_name: str, package: str) -> BECClassConta
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
path = os.path.join(root, file)
|
path = os.path.join(root, file)
|
||||||
rel_dir = os.path.dirname(os.path.relpath(path, directory))
|
subs = os.path.dirname(os.path.relpath(path, directory)).split("/")
|
||||||
if rel_dir in ("", "."):
|
if len(subs) == 1 and not subs[0]:
|
||||||
module_name = file.split(".")[0]
|
module_name = file.split(".")[0]
|
||||||
else:
|
else:
|
||||||
module_name = ".".join(rel_dir.split(os.sep) + [file.split(".")[0]])
|
module_name = ".".join(subs + [file.split(".")[0]])
|
||||||
|
|
||||||
module = importlib.import_module(f"{repo_name}.{package}.{module_name}")
|
module = importlib.import_module(f"{repo_name}.widgets.{module_name}")
|
||||||
|
|
||||||
for name in dir(module):
|
for name in dir(module):
|
||||||
obj = getattr(module, name)
|
obj = getattr(module, name)
|
||||||
@@ -202,30 +203,12 @@ def _collect_classes_from_package(repo_name: str, package: str) -> BECClassConta
|
|||||||
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 len(subs) == 1 and (
|
||||||
|
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
|
||||||
|
):
|
||||||
|
class_info.is_top_level = 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
|
|
||||||
|
|
||||||
|
|
||||||
def get_custom_classes(
|
|
||||||
repo_name: str, packages: tuple[str, ...] | None = None
|
|
||||||
) -> BECClassContainer:
|
|
||||||
"""
|
|
||||||
Get all relevant classes for RPC/CLI in the specified repository.
|
|
||||||
|
|
||||||
By default, discovery is limited to ``<repo>.widgets`` for backward compatibility.
|
|
||||||
Additional package subtrees (for example ``applications``) can be included explicitly.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
repo_name(str): The name of the repository.
|
|
||||||
packages(tuple[str, ...] | None): Optional tuple of package names to scan. Defaults to ("widgets",) for backward compatibility.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
BECClassContainer: Container with collected class information.
|
|
||||||
"""
|
|
||||||
selected_packages = packages or ("widgets",)
|
|
||||||
collection = BECClassContainer()
|
|
||||||
for package in selected_packages:
|
|
||||||
collection += _collect_classes_from_package(repo_name, package)
|
|
||||||
return collection
|
return collection
|
||||||
|
|||||||
@@ -69,11 +69,13 @@ class RoundedFrame(QFrame):
|
|||||||
"""
|
"""
|
||||||
Update the style of the frame based on the background color.
|
Update the style of the frame based on the background color.
|
||||||
"""
|
"""
|
||||||
self.setStyleSheet(f"""
|
self.setStyleSheet(
|
||||||
|
f"""
|
||||||
QFrame#roundedFrame {{
|
QFrame#roundedFrame {{
|
||||||
border-radius: {self._radius}px;
|
border-radius: {self._radius}px;
|
||||||
}}
|
}}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
self.apply_plot_widget_style()
|
self.apply_plot_widget_style()
|
||||||
|
|
||||||
def apply_plot_widget_style(self, border: str = "none"):
|
def apply_plot_widget_style(self, border: str = "none"):
|
||||||
|
|||||||
+25
-101
@@ -4,24 +4,20 @@ import functools
|
|||||||
import traceback
|
import traceback
|
||||||
import types
|
import types
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from typing import TYPE_CHECKING, Callable, Literal, TypeVar
|
from typing import TYPE_CHECKING, Callable, TypeVar
|
||||||
|
|
||||||
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 bec_logger
|
from bec_lib.logger import bec_logger
|
||||||
from bec_lib.utils.import_utils import lazy_import
|
from bec_lib.utils.import_utils import lazy_import
|
||||||
from qtpy.QtCore import Qt, QTimer
|
from qtpy.QtCore import Qt, QTimer
|
||||||
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.error_popups import ErrorPopupUtility
|
from bec_widgets.utils.error_popups import ErrorPopupUtility
|
||||||
from bec_widgets.utils.rpc_register import RPCRegister
|
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||||
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.main_window.main_window import BECMainWindow, BECMainWindowNoRPC
|
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
from bec_lib import messages
|
from bec_lib import messages
|
||||||
@@ -118,14 +114,11 @@ class RPCServer:
|
|||||||
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
|
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
|
||||||
with rpc_exception_hook(functools.partial(self.send_response, request_id, False)):
|
with rpc_exception_hook(functools.partial(self.send_response, request_id, False)):
|
||||||
try:
|
try:
|
||||||
|
obj = self.get_object_from_config(msg["parameter"])
|
||||||
method = msg["action"]
|
method = msg["action"]
|
||||||
args = msg["parameter"].get("args", [])
|
args = msg["parameter"].get("args", [])
|
||||||
kwargs = msg["parameter"].get("kwargs", {})
|
kwargs = msg["parameter"].get("kwargs", {})
|
||||||
if method.startswith("system."):
|
res = self.run_rpc(obj, method, args, kwargs)
|
||||||
res = self.run_system_rpc(method, args, kwargs)
|
|
||||||
else:
|
|
||||||
obj = self.get_object_from_config(msg["parameter"])
|
|
||||||
res = self.run_rpc(obj, method, args, kwargs)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
content = traceback.format_exc()
|
content = traceback.format_exc()
|
||||||
logger.error(f"Error while executing RPC instruction: {content}")
|
logger.error(f"Error while executing RPC instruction: {content}")
|
||||||
@@ -156,7 +149,7 @@ class RPCServer:
|
|||||||
if method == "raise" and hasattr(
|
if method == "raise" and hasattr(
|
||||||
obj, "setWindowState"
|
obj, "setWindowState"
|
||||||
): # special case for raising windows, should work even if minimized
|
): # special case for raising windows, should work even if minimized
|
||||||
# this is a special case for raising windows for gnome on Red Hat (RHEL) 9 systems where changing focus is suppressed by default
|
# this is a special case for raising windows for gnome on rethat 9 systems where changing focus is supressed by default
|
||||||
# The procedure is as follows:
|
# The procedure is as follows:
|
||||||
# 1. Get the current window state to check if the window is minimized and remove minimized flag
|
# 1. Get the current window state to check if the window is minimized and remove minimized flag
|
||||||
# 2. Then in order to force gnome to raise the window, we set the window to stay on top temporarily
|
# 2. Then in order to force gnome to raise the window, we set the window to stay on top temporarily
|
||||||
@@ -181,96 +174,18 @@ class RPCServer:
|
|||||||
obj.show()
|
obj.show()
|
||||||
res = None
|
res = None
|
||||||
else:
|
else:
|
||||||
target_obj, method_obj = self._resolve_rpc_target(obj, method)
|
method_obj = getattr(obj, method)
|
||||||
# check if the method accepts args and kwargs
|
# check if the method accepts args and kwargs
|
||||||
if not callable(method_obj):
|
if not callable(method_obj):
|
||||||
if not args:
|
if not args:
|
||||||
res = method_obj
|
res = method_obj
|
||||||
else:
|
else:
|
||||||
setattr(target_obj, method, args[0])
|
setattr(obj, method, args[0])
|
||||||
res = None
|
res = None
|
||||||
else:
|
else:
|
||||||
res = method_obj(*args, **kwargs)
|
res = method_obj(*args, **kwargs)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
def _resolve_rpc_target(self, obj, method: str) -> tuple[object, object]:
|
|
||||||
"""
|
|
||||||
Resolve a method/property access target for RPC execution.
|
|
||||||
|
|
||||||
Primary target is the object itself. If not found there and the class defines
|
|
||||||
``RPC_CONTENT_CLASS``, unresolved method names can be delegated to the content
|
|
||||||
widget referenced by ``RPC_CONTENT_ATTR`` (default ``content``), but only when
|
|
||||||
the method is explicitly listed in the content class ``USER_ACCESS``.
|
|
||||||
"""
|
|
||||||
if hasattr(obj, method):
|
|
||||||
return obj, getattr(obj, method)
|
|
||||||
|
|
||||||
content_cls = getattr(type(obj), "RPC_CONTENT_CLASS", None)
|
|
||||||
if content_cls is None:
|
|
||||||
raise AttributeError(f"{type(obj).__name__} has no attribute '{method}'")
|
|
||||||
|
|
||||||
content_user_access = set()
|
|
||||||
for entry in getattr(content_cls, "USER_ACCESS", []):
|
|
||||||
if entry.endswith(".setter"):
|
|
||||||
content_user_access.add(entry.split(".setter")[0])
|
|
||||||
else:
|
|
||||||
content_user_access.add(entry)
|
|
||||||
|
|
||||||
if method not in content_user_access:
|
|
||||||
raise AttributeError(f"{type(obj).__name__} has no attribute '{method}'")
|
|
||||||
|
|
||||||
content_attr = getattr(type(obj), "RPC_CONTENT_ATTR", "content")
|
|
||||||
target_obj = getattr(obj, content_attr, None)
|
|
||||||
if target_obj is None:
|
|
||||||
raise AttributeError(
|
|
||||||
f"{type(obj).__name__} has no content target '{content_attr}' for RPC delegation"
|
|
||||||
)
|
|
||||||
if not isinstance(target_obj, content_cls):
|
|
||||||
raise AttributeError(
|
|
||||||
f"{type(obj).__name__}.{content_attr} is not instance of {content_cls.__name__}"
|
|
||||||
)
|
|
||||||
if not hasattr(target_obj, method):
|
|
||||||
raise AttributeError(f"{content_cls.__name__} has no attribute '{method}'")
|
|
||||||
return target_obj, getattr(target_obj, method)
|
|
||||||
|
|
||||||
def run_system_rpc(self, method: str, args: list, kwargs: dict):
|
|
||||||
if method == "system.launch_dock_area":
|
|
||||||
return self._launch_dock_area(*args, **kwargs)
|
|
||||||
if method == "system.list_capabilities":
|
|
||||||
return {"system.launch_dock_area": True}
|
|
||||||
raise ValueError(f"Unknown system RPC method: {method}")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _launch_dock_area(
|
|
||||||
name: str | None = None,
|
|
||||||
geometry: tuple[int, int, int, int] | None = None,
|
|
||||||
startup_profile: str | Literal["restore", "skip"] | None = None,
|
|
||||||
) -> QWidget | None:
|
|
||||||
from bec_widgets.applications import bw_launch
|
|
||||||
|
|
||||||
with RPCRegister.delayed_broadcast() as rpc_register:
|
|
||||||
existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(BECDockArea)
|
|
||||||
if name is not None:
|
|
||||||
WidgetContainerUtils.raise_for_invalid_name(name)
|
|
||||||
if name in existing_dock_areas:
|
|
||||||
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
|
|
||||||
else:
|
|
||||||
name = WidgetContainerUtils.generate_unique_name("dock_area", existing_dock_areas)
|
|
||||||
|
|
||||||
result_widget = bw_launch.dock_area(object_name=name, startup_profile=startup_profile)
|
|
||||||
result_widget.window().setWindowTitle(f"BEC - {name}")
|
|
||||||
|
|
||||||
if isinstance(result_widget, BECMainWindow):
|
|
||||||
apply_window_geometry(result_widget, geometry)
|
|
||||||
result_widget.show()
|
|
||||||
else:
|
|
||||||
window = BECMainWindowNoRPC()
|
|
||||||
window.setCentralWidget(result_widget)
|
|
||||||
window.setWindowTitle(f"BEC - {result_widget.objectName()}")
|
|
||||||
apply_window_geometry(window, geometry)
|
|
||||||
window.show()
|
|
||||||
return result_widget
|
|
||||||
|
|
||||||
def serialize_result_and_send(self, request_id: str, res: object):
|
def serialize_result_and_send(self, request_id: str, res: object):
|
||||||
"""
|
"""
|
||||||
Serialize the result of an RPC call and send it back to the client.
|
Serialize the result of an RPC call and send it back to the client.
|
||||||
@@ -340,9 +255,6 @@ class RPCServer:
|
|||||||
# Respect RPC = False
|
# Respect RPC = False
|
||||||
if getattr(obj, "RPC", True) is False:
|
if getattr(obj, "RPC", True) is False:
|
||||||
return None
|
return None
|
||||||
# Respect rpc_exposed = False
|
|
||||||
if getattr(obj, "rpc_exposed", True) is False:
|
|
||||||
return None
|
|
||||||
return self._serialize_bec_connector(obj, wait=True)
|
return self._serialize_bec_connector(obj, wait=True)
|
||||||
|
|
||||||
def emit_heartbeat(self) -> None:
|
def emit_heartbeat(self) -> None:
|
||||||
@@ -371,8 +283,6 @@ class RPCServer:
|
|||||||
continue
|
continue
|
||||||
if not getattr(val, "RPC", True):
|
if not getattr(val, "RPC", True):
|
||||||
continue
|
continue
|
||||||
if not getattr(val, "rpc_exposed", True):
|
|
||||||
continue
|
|
||||||
data[key] = self._serialize_bec_connector(val)
|
data[key] = self._serialize_bec_connector(val)
|
||||||
if self._broadcasted_data == data:
|
if self._broadcasted_data == data:
|
||||||
return
|
return
|
||||||
@@ -423,9 +333,23 @@ class RPCServer:
|
|||||||
"widget_class": widget_class,
|
"widget_class": widget_class,
|
||||||
"config": config_dict,
|
"config": config_dict,
|
||||||
"container_proxy": container_proxy,
|
"container_proxy": container_proxy,
|
||||||
"__rpc__": getattr(connector, "rpc_exposed", True),
|
"__rpc__": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_becwidget_ancestor(widget: QObject) -> BECConnector | None:
|
||||||
|
"""
|
||||||
|
Traverse up the parent chain to find the nearest BECConnector.
|
||||||
|
Returns None if none is found.
|
||||||
|
"""
|
||||||
|
|
||||||
|
parent = widget.parent()
|
||||||
|
while parent is not None:
|
||||||
|
if isinstance(parent, BECConnector):
|
||||||
|
return parent
|
||||||
|
parent = parent.parent()
|
||||||
|
return None
|
||||||
|
|
||||||
# Suppose clients register callbacks to receive updates
|
# Suppose clients register callbacks to receive updates
|
||||||
def add_registry_update_callback(self, cb: Callable) -> None:
|
def add_registry_update_callback(self, cb: Callable) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -442,5 +366,5 @@ class RPCServer:
|
|||||||
self.status = messages.BECStatus.IDLE
|
self.status = messages.BECStatus.IDLE
|
||||||
self._heartbeat_timer.stop()
|
self._heartbeat_timer.stop()
|
||||||
self.emit_heartbeat()
|
self.emit_heartbeat()
|
||||||
logger.info("Succeeded in shutting down CLI server")
|
logger.info("Succeded in shutting down CLI server")
|
||||||
self.client.shutdown()
|
self.client.shutdown()
|
||||||
|
|||||||
@@ -35,19 +35,16 @@ logger = bec_logger.logger
|
|||||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||||
|
|
||||||
|
|
||||||
def create_action_with_text(toolbar_action, toolbar: QToolBar, min_size: QSize | None = None):
|
def create_action_with_text(toolbar_action, toolbar: QToolBar):
|
||||||
"""
|
"""
|
||||||
Helper function to create a toolbar button with text beside or under the icon.
|
Helper function to create a toolbar button with text beside or under the icon.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
toolbar_action(ToolBarAction): The toolbar action to create the button for.
|
toolbar_action(ToolBarAction): The toolbar action to create the button for.
|
||||||
toolbar(ModularToolBar): The toolbar to add the button to.
|
toolbar(ModularToolBar): The toolbar to add the button to.
|
||||||
min_size(QSize, optional): The minimum size for the button. Defaults to None.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
btn = QToolButton(parent=toolbar)
|
btn = QToolButton(parent=toolbar)
|
||||||
if min_size is not None:
|
|
||||||
btn.setMinimumSize(min_size)
|
|
||||||
if getattr(toolbar_action, "label_text", None):
|
if getattr(toolbar_action, "label_text", None):
|
||||||
toolbar_action.action.setText(toolbar_action.label_text)
|
toolbar_action.action.setText(toolbar_action.label_text)
|
||||||
if getattr(toolbar_action, "tooltip", None):
|
if getattr(toolbar_action, "tooltip", None):
|
||||||
@@ -602,14 +599,16 @@ class ExpandableMenuAction(ToolBarAction):
|
|||||||
button.setIcon(QIcon(self.icon_path))
|
button.setIcon(QIcon(self.icon_path))
|
||||||
button.setText(self.tooltip)
|
button.setText(self.tooltip)
|
||||||
button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
||||||
button.setStyleSheet("""
|
button.setStyleSheet(
|
||||||
|
"""
|
||||||
QToolButton {
|
QToolButton {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
QMenu {
|
QMenu {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
menu = QMenu(button)
|
menu = QMenu(button)
|
||||||
for action_container in self.actions.values():
|
for action_container in self.actions.values():
|
||||||
action: QAction = action_container.action
|
action: QAction = action_container.action
|
||||||
|
|||||||
@@ -106,7 +106,8 @@ class ResizableSpacer(QWidget):
|
|||||||
|
|
||||||
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||||
|
|
||||||
self.setStyleSheet("""
|
self.setStyleSheet(
|
||||||
|
"""
|
||||||
ResizableSpacer {
|
ResizableSpacer {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
margin: 0px;
|
margin: 0px;
|
||||||
@@ -116,7 +117,8 @@ class ResizableSpacer(QWidget):
|
|||||||
ResizableSpacer:hover {
|
ResizableSpacer:hover {
|
||||||
background-color: rgba(100, 100, 200, 80);
|
background-color: rgba(100, 100, 200, 80);
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
self.setContentsMargins(0, 0, 0, 0)
|
self.setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
||||||
|
|||||||
@@ -291,7 +291,8 @@ class ModularToolBar(QToolBar):
|
|||||||
menu = QMenu(self)
|
menu = QMenu(self)
|
||||||
theme = get_theme_name()
|
theme = get_theme_name()
|
||||||
if theme == "dark":
|
if theme == "dark":
|
||||||
menu.setStyleSheet("""
|
menu.setStyleSheet(
|
||||||
|
"""
|
||||||
QMenu {
|
QMenu {
|
||||||
background-color: rgba(50, 50, 50, 0.9);
|
background-color: rgba(50, 50, 50, 0.9);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
@@ -299,10 +300,12 @@ class ModularToolBar(QToolBar):
|
|||||||
QMenu::item:selected {
|
QMenu::item:selected {
|
||||||
background-color: rgba(0, 0, 255, 0.2);
|
background-color: rgba(0, 0, 255, 0.2);
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Light theme styling
|
# Light theme styling
|
||||||
menu.setStyleSheet("""
|
menu.setStyleSheet(
|
||||||
|
"""
|
||||||
QMenu {
|
QMenu {
|
||||||
background-color: rgba(255, 255, 255, 0.9);
|
background-color: rgba(255, 255, 255, 0.9);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||||
@@ -310,7 +313,8 @@ class ModularToolBar(QToolBar):
|
|||||||
QMenu::item:selected {
|
QMenu::item:selected {
|
||||||
background-color: rgba(0, 0, 255, 0.2);
|
background-color: rgba(0, 0, 255, 0.2);
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
for ii, bundle in enumerate(self.shown_bundles):
|
for ii, bundle in enumerate(self.shown_bundles):
|
||||||
self.handle_bundle_context_menu(menu, bundle)
|
self.handle_bundle_context_menu(menu, bundle)
|
||||||
if ii < len(self.shown_bundles) - 1:
|
if ii < len(self.shown_bundles) - 1:
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import shiboken6
|
|
||||||
from qtpy.QtCore import QPropertyAnimation, QRect, QSequentialAnimationGroup, Qt
|
|
||||||
from qtpy.QtWidgets import QFrame, QWidget
|
|
||||||
|
|
||||||
|
|
||||||
class WidgetHighlighter:
|
|
||||||
"""
|
|
||||||
Utility that highlights widgets by drawing a temporary frame around them.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
frame_parent: QWidget | None = None,
|
|
||||||
window_flags: Qt.WindowType | Qt.WindowFlags = Qt.WindowType.Tool
|
|
||||||
| Qt.WindowType.FramelessWindowHint
|
|
||||||
| Qt.WindowType.WindowStaysOnTopHint,
|
|
||||||
style_sheet: str = "border: 2px solid #FF00FF; border-radius: 6px; background: transparent;",
|
|
||||||
) -> None:
|
|
||||||
self._frame_parent = frame_parent
|
|
||||||
self._window_flags = window_flags
|
|
||||||
self._style_sheet = style_sheet
|
|
||||||
self._frame: QFrame | None = None
|
|
||||||
self._animation_group: QSequentialAnimationGroup | None = None
|
|
||||||
|
|
||||||
def highlight(self, widget: QWidget | None) -> None:
|
|
||||||
"""
|
|
||||||
Highlight the given widget with a pulsing frame.
|
|
||||||
"""
|
|
||||||
if widget is None or not shiboken6.isValid(widget):
|
|
||||||
return
|
|
||||||
|
|
||||||
frame = self._ensure_frame()
|
|
||||||
frame.hide()
|
|
||||||
|
|
||||||
geom = widget.frameGeometry()
|
|
||||||
top_left = widget.mapToGlobal(widget.rect().topLeft())
|
|
||||||
frame.setGeometry(top_left.x(), top_left.y(), geom.width(), geom.height())
|
|
||||||
frame.setWindowOpacity(1.0)
|
|
||||||
frame.show()
|
|
||||||
|
|
||||||
start_rect = QRect(
|
|
||||||
top_left.x() - 5, top_left.y() - 5, geom.width() + 10, geom.height() + 10
|
|
||||||
)
|
|
||||||
|
|
||||||
pulse = QPropertyAnimation(frame, b"geometry", frame)
|
|
||||||
pulse.setDuration(300)
|
|
||||||
pulse.setStartValue(start_rect)
|
|
||||||
pulse.setEndValue(QRect(top_left.x(), top_left.y(), geom.width(), geom.height()))
|
|
||||||
|
|
||||||
fade = QPropertyAnimation(frame, b"windowOpacity", frame)
|
|
||||||
fade.setDuration(2000)
|
|
||||||
fade.setStartValue(1.0)
|
|
||||||
fade.setEndValue(0.0)
|
|
||||||
fade.finished.connect(frame.hide)
|
|
||||||
|
|
||||||
if self._animation_group is not None:
|
|
||||||
old_group = self._animation_group
|
|
||||||
self._animation_group = None
|
|
||||||
old_group.stop()
|
|
||||||
old_group.deleteLater()
|
|
||||||
|
|
||||||
animation = QSequentialAnimationGroup(frame)
|
|
||||||
animation.addAnimation(pulse)
|
|
||||||
animation.addAnimation(fade)
|
|
||||||
animation.start()
|
|
||||||
|
|
||||||
self._animation_group = animation
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
"""
|
|
||||||
Delete the highlight frame and cancel pending animations.
|
|
||||||
"""
|
|
||||||
if self._animation_group is not None:
|
|
||||||
self._animation_group.stop()
|
|
||||||
self._animation_group.deleteLater()
|
|
||||||
self._animation_group = None
|
|
||||||
if self._frame is not None:
|
|
||||||
self._frame.hide()
|
|
||||||
self._frame.deleteLater()
|
|
||||||
self._frame = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def frame(self) -> QFrame | None:
|
|
||||||
"""Return the currently allocated highlight frame (if any)."""
|
|
||||||
return self._frame
|
|
||||||
|
|
||||||
def _ensure_frame(self) -> QFrame:
|
|
||||||
if self._frame is None:
|
|
||||||
self._frame = QFrame(self._frame_parent, self._window_flags)
|
|
||||||
self._frame.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
|
||||||
self._frame.setStyleSheet(self._style_sheet)
|
|
||||||
return self._frame
|
|
||||||
+55
-117
@@ -2,12 +2,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import TYPE_CHECKING, Type, TypeVar, cast
|
from typing import TYPE_CHECKING, Type, TypeVar, cast
|
||||||
|
|
||||||
import shiboken6 as shb
|
import shiboken6 as shb
|
||||||
from bec_lib import bec_logger
|
from bec_lib import bec_logger
|
||||||
from qtpy.QtCore import Qt
|
|
||||||
from qtpy.QtWidgets import (
|
from qtpy.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
@@ -26,21 +24,13 @@ 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.bec_connector import BECConnector
|
from bec_widgets.utils import BECConnector
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
|
|
||||||
TAncestor = TypeVar("TAncestor", bound=QWidget)
|
TAncestor = TypeVar("TAncestor", bound=QWidget)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class WidgetTreeNode:
|
|
||||||
widget: QWidget
|
|
||||||
parent: QWidget | None
|
|
||||||
depth: int
|
|
||||||
prefix: str
|
|
||||||
|
|
||||||
|
|
||||||
class WidgetHandler(ABC):
|
class WidgetHandler(ABC):
|
||||||
"""Abstract base class for all widget handlers."""
|
"""Abstract base class for all widget handlers."""
|
||||||
|
|
||||||
@@ -330,72 +320,6 @@ class WidgetIO:
|
|||||||
|
|
||||||
|
|
||||||
class WidgetHierarchy:
|
class WidgetHierarchy:
|
||||||
@staticmethod
|
|
||||||
def iter_widget_tree(widget: QWidget, *, exclude_internal_widgets: bool = True):
|
|
||||||
"""
|
|
||||||
Yield WidgetTreeNode entries for the widget hierarchy.
|
|
||||||
"""
|
|
||||||
visited: set[int] = set()
|
|
||||||
yield from WidgetHierarchy._iter_widget_tree_nodes(
|
|
||||||
widget, None, exclude_internal_widgets, visited, [], 0
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _iter_widget_tree_nodes(
|
|
||||||
widget: QWidget,
|
|
||||||
parent: QWidget | None,
|
|
||||||
exclude_internal_widgets: bool,
|
|
||||||
visited: set[int],
|
|
||||||
branch_flags: list[bool],
|
|
||||||
depth: int,
|
|
||||||
):
|
|
||||||
if widget is None or not shb.isValid(widget):
|
|
||||||
return
|
|
||||||
widget_id = id(widget)
|
|
||||||
if widget_id in visited:
|
|
||||||
return
|
|
||||||
visited.add(widget_id)
|
|
||||||
|
|
||||||
prefix = WidgetHierarchy._build_prefix(branch_flags)
|
|
||||||
yield WidgetTreeNode(widget=widget, parent=parent, depth=depth, prefix=prefix)
|
|
||||||
|
|
||||||
children = WidgetHierarchy._filtered_children(widget, exclude_internal_widgets)
|
|
||||||
for idx, child in enumerate(children):
|
|
||||||
is_last = idx == len(children) - 1
|
|
||||||
yield from WidgetHierarchy._iter_widget_tree_nodes(
|
|
||||||
child,
|
|
||||||
widget,
|
|
||||||
exclude_internal_widgets,
|
|
||||||
visited,
|
|
||||||
branch_flags + [is_last],
|
|
||||||
depth + 1,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_prefix(branch_flags: list[bool]) -> str:
|
|
||||||
if not branch_flags:
|
|
||||||
return ""
|
|
||||||
parts: list[str] = []
|
|
||||||
for flag in branch_flags[:-1]:
|
|
||||||
parts.append(" " if flag else "│ ")
|
|
||||||
parts.append("└─ " if branch_flags[-1] else "├─ ")
|
|
||||||
return "".join(parts)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _filtered_children(widget: QWidget, exclude_internal_widgets: bool) -> list[QWidget]:
|
|
||||||
children: list[QWidget] = []
|
|
||||||
for child in widget.findChildren(QWidget, options=Qt.FindDirectChildrenOnly):
|
|
||||||
if not shb.isValid(child):
|
|
||||||
continue
|
|
||||||
if (
|
|
||||||
exclude_internal_widgets
|
|
||||||
and isinstance(widget, QComboBox)
|
|
||||||
and child.__class__.__name__ in ["QFrame", "QBoxLayout", "QListView"]
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
children.append(child)
|
|
||||||
return children
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def print_widget_hierarchy(
|
def print_widget_hierarchy(
|
||||||
widget,
|
widget,
|
||||||
@@ -418,36 +342,55 @@ 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.bec_connector import BECConnector
|
from bec_widgets.utils 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(
|
# 1) Filter out widgets that are not BECConnectors (if 'only_bec_widgets' is True)
|
||||||
widget, exclude_internal_widgets=exclude_internal_widgets
|
is_bec = isinstance(widget, BECConnector)
|
||||||
):
|
if only_bec_widgets and not is_bec:
|
||||||
current = node.widget
|
return
|
||||||
is_bec = isinstance(current, BECConnector)
|
|
||||||
if only_bec_widgets and not is_bec:
|
# 2) Determine and print the parent's info (closest BECConnector)
|
||||||
|
parent_info = ""
|
||||||
|
if show_parent and is_bec:
|
||||||
|
ancestor = WidgetHierarchy._get_becwidget_ancestor(widget)
|
||||||
|
if ancestor:
|
||||||
|
parent_label = ancestor.objectName() or ancestor.__class__.__name__
|
||||||
|
parent_info = f" parent={parent_label}"
|
||||||
|
else:
|
||||||
|
parent_info = " parent=None"
|
||||||
|
|
||||||
|
widget_info = f"{widget.__class__.__name__} ({widget.objectName()}){parent_info}"
|
||||||
|
print(prefix + widget_info)
|
||||||
|
|
||||||
|
# 3) If it's a Waveform, explicitly print the curves
|
||||||
|
if isinstance(widget, Waveform):
|
||||||
|
for curve in widget.curves:
|
||||||
|
curve_prefix = prefix + " └─ "
|
||||||
|
print(
|
||||||
|
f"{curve_prefix}{curve.__class__.__name__} ({curve.objectName()}) "
|
||||||
|
f"parent={widget.objectName()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4) Recursively handle each child if:
|
||||||
|
# - It's a QWidget
|
||||||
|
# - It is a BECConnector (or we don't care about filtering)
|
||||||
|
# - Its closest BECConnector parent is the current widget
|
||||||
|
for child in widget.findChildren(QWidget):
|
||||||
|
if only_bec_widgets and not isinstance(child, BECConnector):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
parent_info = ""
|
# if WidgetHierarchy._get_becwidget_ancestor(child) == widget:
|
||||||
if show_parent and is_bec:
|
child_prefix = prefix + " └─ "
|
||||||
ancestor = WidgetHierarchy.get_becwidget_ancestor(current)
|
WidgetHierarchy.print_widget_hierarchy(
|
||||||
if ancestor:
|
child,
|
||||||
parent_label = ancestor.objectName() or ancestor.__class__.__name__
|
indent=indent + 1,
|
||||||
parent_info = f" parent={parent_label}"
|
grab_values=grab_values,
|
||||||
else:
|
prefix=child_prefix,
|
||||||
parent_info = " parent=None"
|
exclude_internal_widgets=exclude_internal_widgets,
|
||||||
|
only_bec_widgets=only_bec_widgets,
|
||||||
widget_info = f"{current.__class__.__name__} ({current.objectName()}){parent_info}"
|
show_parent=show_parent,
|
||||||
print(node.prefix + widget_info)
|
)
|
||||||
|
|
||||||
if isinstance(current, Waveform):
|
|
||||||
for curve in current.curves:
|
|
||||||
curve_prefix = node.prefix + " "
|
|
||||||
print(
|
|
||||||
f"{curve_prefix}└─ {curve.__class__.__name__} ({curve.objectName()}) "
|
|
||||||
f"parent={current.objectName()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def print_becconnector_hierarchy_from_app():
|
def print_becconnector_hierarchy_from_app():
|
||||||
@@ -468,7 +411,7 @@ class WidgetHierarchy:
|
|||||||
|
|
||||||
from qtpy.QtWidgets import QApplication
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
from bec_widgets.utils.bec_connector import BECConnector
|
from bec_widgets.utils 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
|
||||||
@@ -487,7 +430,7 @@ class WidgetHierarchy:
|
|||||||
# 3) Build a map of (closest BECConnector parent) -> list of children
|
# 3) Build a map of (closest BECConnector parent) -> list of children
|
||||||
parent_map = defaultdict(list)
|
parent_map = defaultdict(list)
|
||||||
for w in bec_widgets:
|
for w in bec_widgets:
|
||||||
parent_bec = WidgetHierarchy.get_becwidget_ancestor(w)
|
parent_bec = WidgetHierarchy._get_becwidget_ancestor(w)
|
||||||
parent_map[parent_bec].append(w)
|
parent_map[parent_bec].append(w)
|
||||||
|
|
||||||
# 4) Define a recursive printer to show each object's children
|
# 4) Define a recursive printer to show each object's children
|
||||||
@@ -524,17 +467,12 @@ class WidgetHierarchy:
|
|||||||
print_tree(root, prefix=" ")
|
print_tree(root, prefix=" ")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_becwidget_ancestor(widget):
|
def _get_becwidget_ancestor(widget):
|
||||||
"""
|
"""
|
||||||
Traverse up the parent chain to find the nearest BECConnector.
|
Traverse up the parent chain to find the nearest BECConnector.
|
||||||
|
Returns None if none is found.
|
||||||
Args:
|
|
||||||
widget: Starting widget to find the ancestor for.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The nearest ancestor that is a BECConnector, or None if not found.
|
|
||||||
"""
|
"""
|
||||||
from bec_widgets.utils.bec_connector import BECConnector
|
from bec_widgets.utils 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,13 +574,13 @@ 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.bec_connector import BECConnector
|
from bec_widgets.utils import BECConnector
|
||||||
|
|
||||||
connectors: list[BECConnector] = []
|
connectors: list[BECConnector] = []
|
||||||
if isinstance(widget, BECConnector):
|
if isinstance(widget, BECConnector):
|
||||||
connectors.append(widget)
|
connectors.append(widget)
|
||||||
for child in widget.findChildren(BECConnector):
|
for child in widget.findChildren(BECConnector):
|
||||||
if WidgetHierarchy.get_becwidget_ancestor(child) is widget:
|
if WidgetHierarchy._get_becwidget_ancestor(child) is widget:
|
||||||
connectors.append(child)
|
connectors.append(child)
|
||||||
return connectors
|
return connectors
|
||||||
|
|
||||||
@@ -664,7 +602,7 @@ class WidgetHierarchy:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from bec_widgets.utils.bec_connector import BECConnector # local import to avoid cycles
|
from bec_widgets.utils 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):
|
||||||
@@ -673,7 +611,7 @@ class WidgetHierarchy:
|
|||||||
is_bec_target = issubclass(ancestor_class, BECConnector)
|
is_bec_target = issubclass(ancestor_class, BECConnector)
|
||||||
|
|
||||||
if is_bec_target:
|
if is_bec_target:
|
||||||
ancestor = WidgetHierarchy.get_becwidget_ancestor(widget)
|
ancestor = WidgetHierarchy._get_becwidget_ancestor(widget)
|
||||||
return cast(TAncestor, ancestor)
|
return cast(TAncestor, ancestor)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error importing BECConnector: {e}")
|
logger.error(f"Error importing BECConnector: {e}")
|
||||||
|
|||||||
@@ -41,12 +41,12 @@ class AutoUpdates(BECMainWindow):
|
|||||||
parent=self,
|
parent=self,
|
||||||
object_name="dock_area",
|
object_name="dock_area",
|
||||||
enable_profile_management=False,
|
enable_profile_management=False,
|
||||||
startup_profile="skip",
|
restore_initial_profile=False,
|
||||||
)
|
)
|
||||||
self.setCentralWidget(self.dock_area)
|
self.setCentralWidget(self.dock_area)
|
||||||
self._auto_update_selected_device: str | None = None
|
self._auto_update_selected_device: str | None = None
|
||||||
|
|
||||||
self._default_dock = None # type: ignore
|
self._default_dock = None # type:ignore
|
||||||
self.current_widget: BECWidget | None = None
|
self.current_widget: BECWidget | None = None
|
||||||
self.dock_name = None
|
self.dock_name = None
|
||||||
self._enabled = True
|
self._enabled = True
|
||||||
@@ -63,7 +63,7 @@ class AutoUpdates(BECMainWindow):
|
|||||||
Disconnect all connections for the auto updates.
|
Disconnect all connections for the auto updates.
|
||||||
"""
|
"""
|
||||||
self.bec_dispatcher.disconnect_slot(
|
self.bec_dispatcher.disconnect_slot(
|
||||||
self._on_scan_status, MessageEndpoints.scan_status() # type: ignore
|
self._on_scan_status, MessageEndpoints.scan_status() # type:ignore
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -244,10 +244,10 @@ class AutoUpdates(BECMainWindow):
|
|||||||
wf = self.set_dock_to_widget("Waveform")
|
wf = self.set_dock_to_widget("Waveform")
|
||||||
|
|
||||||
# Get the scan report devices reported by the scan
|
# Get the scan report devices reported by the scan
|
||||||
dev_x = info.scan_report_devices[0] # type: ignore
|
dev_x = info.scan_report_devices[0] # type:ignore
|
||||||
|
|
||||||
# For the y axis, get the selected device
|
# For the y axis, get the selected device
|
||||||
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type: ignore
|
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
|
||||||
if not dev_y:
|
if not dev_y:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -256,8 +256,8 @@ class AutoUpdates(BECMainWindow):
|
|||||||
# as the label and title
|
# as the label and title
|
||||||
wf.clear_all()
|
wf.clear_all()
|
||||||
wf.plot(
|
wf.plot(
|
||||||
device_x=dev_x,
|
x_name=dev_x,
|
||||||
device_y=dev_y,
|
y_name=dev_y,
|
||||||
label=f"Scan {info.scan_number} - {dev_y}",
|
label=f"Scan {info.scan_number} - {dev_y}",
|
||||||
title=f"Scan {info.scan_number}",
|
title=f"Scan {info.scan_number}",
|
||||||
x_label=dev_x,
|
x_label=dev_x,
|
||||||
@@ -265,7 +265,7 @@ class AutoUpdates(BECMainWindow):
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Auto Update [simple_line_scan]: Started plot with: device_x={dev_x}, device_y={dev_y}"
|
f"Auto Update [simple_line_scan]: Started plot with: x_name={dev_x}, y_name={dev_y}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def simple_grid_scan(self, info: ScanStatusMessage) -> None:
|
def simple_grid_scan(self, info: ScanStatusMessage) -> None:
|
||||||
@@ -279,8 +279,8 @@ class AutoUpdates(BECMainWindow):
|
|||||||
scatter = self.set_dock_to_widget("ScatterWaveform")
|
scatter = self.set_dock_to_widget("ScatterWaveform")
|
||||||
|
|
||||||
# Get the scan report devices reported by the scan
|
# Get the scan report devices reported by the scan
|
||||||
dev_x, dev_y = info.scan_report_devices[0], info.scan_report_devices[1] # type: ignore
|
dev_x, dev_y = info.scan_report_devices[0], info.scan_report_devices[1] # type:ignore
|
||||||
dev_z = self.get_selected_device(info.readout_priority["monitored"]) # type: ignore
|
dev_z = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
|
||||||
|
|
||||||
if None in (dev_x, dev_y, dev_z):
|
if None in (dev_x, dev_y, dev_z):
|
||||||
return
|
return
|
||||||
@@ -288,14 +288,11 @@ class AutoUpdates(BECMainWindow):
|
|||||||
# Clear the scatter waveform widget and plot the data
|
# Clear the scatter waveform widget and plot the data
|
||||||
scatter.clear_all()
|
scatter.clear_all()
|
||||||
scatter.plot(
|
scatter.plot(
|
||||||
device_x=dev_x,
|
x_name=dev_x, y_name=dev_y, z_name=dev_z, label=f"Scan {info.scan_number} - {dev_z}"
|
||||||
device_y=dev_y,
|
|
||||||
device_z=dev_z,
|
|
||||||
label=f"Scan {info.scan_number} - {dev_z}",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Auto Update [simple_grid_scan]: Started plot with: device_x={dev_x}, device_y={dev_y}, device_z={dev_z}"
|
f"Auto Update [simple_grid_scan]: Started plot with: x_name={dev_x}, y_name={dev_y}, z_name={dev_z}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def best_effort(self, info: ScanStatusMessage) -> None:
|
def best_effort(self, info: ScanStatusMessage) -> None:
|
||||||
@@ -309,8 +306,8 @@ class AutoUpdates(BECMainWindow):
|
|||||||
# If the scan report devices are empty, there is nothing we can do
|
# If the scan report devices are empty, there is nothing we can do
|
||||||
if not info.scan_report_devices:
|
if not info.scan_report_devices:
|
||||||
return
|
return
|
||||||
dev_x = info.scan_report_devices[0] # type: ignore
|
dev_x = info.scan_report_devices[0] # type:ignore
|
||||||
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type: ignore
|
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
|
||||||
if not dev_y:
|
if not dev_y:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -320,17 +317,15 @@ class AutoUpdates(BECMainWindow):
|
|||||||
# Clear the waveform widget and plot the data
|
# Clear the waveform widget and plot the data
|
||||||
wf.clear_all()
|
wf.clear_all()
|
||||||
wf.plot(
|
wf.plot(
|
||||||
device_x=dev_x,
|
x_name=dev_x,
|
||||||
device_y=dev_y,
|
y_name=dev_y,
|
||||||
label=f"Scan {info.scan_number} - {dev_y}",
|
label=f"Scan {info.scan_number} - {dev_y}",
|
||||||
title=f"Scan {info.scan_number}",
|
title=f"Scan {info.scan_number}",
|
||||||
x_label=dev_x,
|
x_label=dev_x,
|
||||||
y_label=dev_y,
|
y_label=dev_y,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(f"Auto Update [best_effort]: Started plot with: x_name={dev_x}, y_name={dev_y}")
|
||||||
f"Auto Update [best_effort]: Started plot with: device_x={dev_x}, device_y={dev_y}"
|
|
||||||
)
|
|
||||||
|
|
||||||
#######################################################################
|
#######################################################################
|
||||||
################# GUI Callbacks #######################################
|
################# GUI Callbacks #######################################
|
||||||
|
|||||||
@@ -13,9 +13,8 @@ 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.utils.bec_connector import BECConnector
|
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||||
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,
|
||||||
@@ -113,7 +112,6 @@ class DockAreaWidget(BECWidget, QWidget):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self._root_layout.addWidget(self.dock_manager, 1)
|
self._root_layout.addWidget(self.dock_manager, 1)
|
||||||
self._install_manager_parent_guards()
|
|
||||||
|
|
||||||
################################################################################
|
################################################################################
|
||||||
# Dock Utility Helpers
|
# Dock Utility Helpers
|
||||||
@@ -256,54 +254,6 @@ class DockAreaWidget(BECWidget, QWidget):
|
|||||||
|
|
||||||
return lambda dock: self._default_close_handler(dock, widget)
|
return lambda dock: self._default_close_handler(dock, widget)
|
||||||
|
|
||||||
def _install_manager_parent_guards(self) -> None:
|
|
||||||
"""
|
|
||||||
Track ADS structural changes so drag/drop-created tab areas keep stable parenting.
|
|
||||||
"""
|
|
||||||
self.dock_manager.dockAreaCreated.connect(self._normalize_all_dock_parents)
|
|
||||||
self.dock_manager.dockWidgetAdded.connect(self._normalize_all_dock_parents)
|
|
||||||
self.dock_manager.stateRestored.connect(self._normalize_all_dock_parents)
|
|
||||||
self.dock_manager.restoringState.connect(self._normalize_all_dock_parents)
|
|
||||||
self.dock_manager.focusedDockWidgetChanged.connect(self._normalize_all_dock_parents)
|
|
||||||
self._normalize_all_dock_parents()
|
|
||||||
|
|
||||||
def _iter_all_dock_areas(self) -> list[CDockAreaWidget]:
|
|
||||||
"""Return all dock areas from all known dock containers."""
|
|
||||||
areas: list[CDockAreaWidget] = []
|
|
||||||
for i in range(self.dock_manager.dockAreaCount()):
|
|
||||||
area = self.dock_manager.dockArea(i)
|
|
||||||
if area is None or not isValid(area):
|
|
||||||
continue
|
|
||||||
areas.append(area)
|
|
||||||
return areas
|
|
||||||
|
|
||||||
def _connect_dock_area_parent_guards(self) -> None:
|
|
||||||
"""Bind area-level tab/view events to parent normalization."""
|
|
||||||
for area in self._iter_all_dock_areas():
|
|
||||||
try:
|
|
||||||
area.currentChanged.connect(
|
|
||||||
self._normalize_all_dock_parents, Qt.ConnectionType.UniqueConnection
|
|
||||||
)
|
|
||||||
area.viewToggled.connect(
|
|
||||||
self._normalize_all_dock_parents, Qt.ConnectionType.UniqueConnection
|
|
||||||
)
|
|
||||||
except TypeError:
|
|
||||||
area.currentChanged.connect(self._normalize_all_dock_parents)
|
|
||||||
area.viewToggled.connect(self._normalize_all_dock_parents)
|
|
||||||
|
|
||||||
def _normalize_all_dock_parents(self, *_args) -> None:
|
|
||||||
"""
|
|
||||||
Ensure each dock has a stable parent after tab switches, re-docking, or restore.
|
|
||||||
"""
|
|
||||||
self._connect_dock_area_parent_guards()
|
|
||||||
for dock in self.dock_list():
|
|
||||||
if dock is None or not isValid(dock):
|
|
||||||
continue
|
|
||||||
area_widget = dock.dockAreaWidget()
|
|
||||||
target_parent = area_widget if area_widget is not None else self.dock_manager
|
|
||||||
if dock.parent() is not target_parent:
|
|
||||||
dock.setParent(target_parent)
|
|
||||||
|
|
||||||
def _make_dock(
|
def _make_dock(
|
||||||
self,
|
self,
|
||||||
widget: QWidget,
|
widget: QWidget,
|
||||||
@@ -406,7 +356,6 @@ class DockAreaWidget(BECWidget, QWidget):
|
|||||||
self._apply_floating_state_to_dock(dock, floating_state)
|
self._apply_floating_state_to_dock(dock, floating_state)
|
||||||
if resolved_icon is not None:
|
if resolved_icon is not None:
|
||||||
dock.setIcon(resolved_icon)
|
dock.setIcon(resolved_icon)
|
||||||
self._normalize_all_dock_parents()
|
|
||||||
return dock
|
return dock
|
||||||
|
|
||||||
def _delete_dock(self, dock: CDockWidget) -> None:
|
def _delete_dock(self, dock: CDockWidget) -> None:
|
||||||
@@ -1315,7 +1264,7 @@ class DockAreaWidget(BECWidget, QWidget):
|
|||||||
or a sequence of button names to hide.
|
or a sequence of button names to hide.
|
||||||
show_settings_action(bool | None): Control whether a dock settings/property action should
|
show_settings_action(bool | None): Control whether a dock settings/property action should
|
||||||
be installed. Defaults to ``False`` for the basic dock area; subclasses
|
be installed. Defaults to ``False`` for the basic dock area; subclasses
|
||||||
such as `BECDockArea` override the default to ``True``.
|
such as `AdvancedDockArea` override the default to ``True``.
|
||||||
promote_central(bool): When True, promote the created dock to be the dock manager's
|
promote_central(bool): When True, promote the created dock to be the dock manager's
|
||||||
central widget (useful for editor stacks or other root content).
|
central widget (useful for editor stacks or other root content).
|
||||||
dock_icon(QIcon | None): Optional icon applied to the dock via ``CDockWidget.setIcon``.
|
dock_icon(QIcon | None): Optional icon applied to the dock via ``CDockWidget.setIcon``.
|
||||||
@@ -1385,40 +1334,37 @@ class DockAreaWidget(BECWidget, QWidget):
|
|||||||
dock = self._create_dock_from_spec(spec)
|
dock = self._create_dock_from_spec(spec)
|
||||||
return dock if return_dock else widget
|
return dock if return_dock else widget
|
||||||
|
|
||||||
|
def _iter_all_docks(self) -> list[CDockWidget]:
|
||||||
|
"""Return all docks, including those hosted in floating containers."""
|
||||||
|
docks = list(self.dock_manager.dockWidgets())
|
||||||
|
seen = {id(d) for d in docks}
|
||||||
|
for container in self.dock_manager.floatingWidgets():
|
||||||
|
if container is None:
|
||||||
|
continue
|
||||||
|
for dock in container.dockWidgets():
|
||||||
|
if dock is None:
|
||||||
|
continue
|
||||||
|
if id(dock) in seen:
|
||||||
|
continue
|
||||||
|
docks.append(dock)
|
||||||
|
seen.add(id(dock))
|
||||||
|
return docks
|
||||||
|
|
||||||
def dock_map(self) -> dict[str, CDockWidget]:
|
def dock_map(self) -> dict[str, CDockWidget]:
|
||||||
"""Return the dock widgets map as dictionary with names as keys."""
|
"""Return the dock widgets map as dictionary with names as keys."""
|
||||||
return self.dock_manager.dockWidgetsMap()
|
return {dock.objectName(): dock for dock in self._iter_all_docks() if dock.objectName()}
|
||||||
|
|
||||||
def dock_list(self) -> list[CDockWidget]:
|
def dock_list(self) -> list[CDockWidget]:
|
||||||
"""Return the list of dock widgets."""
|
"""Return the list of dock widgets."""
|
||||||
return list(self.dock_map().values())
|
return self._iter_all_docks()
|
||||||
|
|
||||||
def widget_map(self, bec_widgets_only: bool = True) -> dict[str, QWidget]:
|
def widget_map(self) -> dict[str, QWidget]:
|
||||||
"""
|
"""Return a dictionary mapping widget names to their corresponding widgets."""
|
||||||
Return a dictionary mapping widget names to their corresponding widgets.
|
return {dock.objectName(): dock.widget() for dock in self.dock_list()}
|
||||||
|
|
||||||
Args:
|
def widget_list(self) -> list[QWidget]:
|
||||||
bec_widgets_only(bool): If True, only include widgets that are BECConnector instances.
|
"""Return a list of all widgets contained in the dock area."""
|
||||||
"""
|
return [dock.widget() for dock in self.dock_list() if isinstance(dock.widget(), QWidget)]
|
||||||
|
|
||||||
widgets: dict[str, QWidget] = {}
|
|
||||||
for dock in self.dock_list():
|
|
||||||
widget = dock.widget()
|
|
||||||
if not isinstance(widget, QWidget):
|
|
||||||
continue
|
|
||||||
if bec_widgets_only and not isinstance(widget, BECConnector):
|
|
||||||
continue
|
|
||||||
widgets[dock.objectName()] = widget
|
|
||||||
return widgets
|
|
||||||
|
|
||||||
def widget_list(self, bec_widgets_only: bool = True) -> list[QWidget]:
|
|
||||||
"""
|
|
||||||
Return a list of widgets contained in the dock area.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
bec_widgets_only(bool): If True, only include widgets that are BECConnector instances.
|
|
||||||
"""
|
|
||||||
return list(self.widget_map(bec_widgets_only=bec_widgets_only).values())
|
|
||||||
|
|
||||||
@SafeSlot()
|
@SafeSlot()
|
||||||
def attach_all(self):
|
def attach_all(self):
|
||||||
|
|||||||
@@ -19,12 +19,10 @@ 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.rpc.rpc_widget_handler import widget_handler
|
||||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
from bec_widgets.utils import BECDispatcher
|
||||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
|
||||||
from bec_widgets.utils.colors import apply_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
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,
|
||||||
@@ -70,7 +68,7 @@ from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
|||||||
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.device_control.positioner_box import PositionerBox, PositionerBox2D
|
||||||
from bec_widgets.widgets.control.scan_control import ScanControl
|
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.editors.web_console.web_console import BECShell, WebConsole
|
||||||
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
|
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
|
||||||
from bec_widgets.widgets.plots.image.image import Image
|
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.motor_map.motor_map import MotorMap
|
||||||
@@ -80,7 +78,7 @@ 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.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_queue.bec_queue import BECQueue
|
||||||
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
|
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
|
||||||
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel
|
from bec_widgets.widgets.utility.logpanel import LogPanel
|
||||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
@@ -88,7 +86,6 @@ logger = bec_logger.logger
|
|||||||
_PROFILE_NAMESPACE_UNSET = object()
|
_PROFILE_NAMESPACE_UNSET = object()
|
||||||
|
|
||||||
PROFILE_STATE_KEYS = {key: SETTINGS_KEYS[key] for key in ("geom", "state", "ads_state")}
|
PROFILE_STATE_KEYS = {key: SETTINGS_KEYS[key] for key in ("geom", "state", "ads_state")}
|
||||||
StartupProfile = Literal["restore", "skip"] | str | None
|
|
||||||
|
|
||||||
|
|
||||||
class BECDockArea(DockAreaWidget):
|
class BECDockArea(DockAreaWidget):
|
||||||
@@ -126,7 +123,9 @@ class BECDockArea(DockAreaWidget):
|
|||||||
instance_id: str | None = None,
|
instance_id: str | None = None,
|
||||||
auto_save_upon_exit: bool = True,
|
auto_save_upon_exit: bool = True,
|
||||||
enable_profile_management: bool = True,
|
enable_profile_management: bool = True,
|
||||||
startup_profile: StartupProfile = "restore",
|
restore_initial_profile: bool = True,
|
||||||
|
init_profile: str | None = None,
|
||||||
|
start_empty: bool = False,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
self._profile_namespace_hint = profile_namespace
|
self._profile_namespace_hint = profile_namespace
|
||||||
@@ -135,9 +134,14 @@ class BECDockArea(DockAreaWidget):
|
|||||||
self._instance_id = slugify.slugify(instance_id, separator="_") if instance_id else None
|
self._instance_id = slugify.slugify(instance_id, separator="_") if instance_id else None
|
||||||
self._auto_save_upon_exit = auto_save_upon_exit
|
self._auto_save_upon_exit = auto_save_upon_exit
|
||||||
self._profile_management_enabled = enable_profile_management
|
self._profile_management_enabled = enable_profile_management
|
||||||
self._startup_profile = self._normalize_startup_profile(startup_profile)
|
self._restore_initial_profile = restore_initial_profile
|
||||||
|
self._init_profile = init_profile
|
||||||
|
self._start_empty = start_empty
|
||||||
super().__init__(
|
super().__init__(
|
||||||
parent, default_add_direction=default_add_direction, title="BEC Dock Area", **kwargs
|
parent,
|
||||||
|
default_add_direction=default_add_direction,
|
||||||
|
title="Advanced Dock Area",
|
||||||
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize mode property first (before toolbar setup)
|
# Initialize mode property first (before toolbar setup)
|
||||||
@@ -157,16 +161,14 @@ class BECDockArea(DockAreaWidget):
|
|||||||
self._root_layout.insertWidget(0, self.toolbar)
|
self._root_layout.insertWidget(0, self.toolbar)
|
||||||
|
|
||||||
# Populate and hook the workspace combo
|
# Populate and hook the workspace combo
|
||||||
|
self._refresh_workspace_list()
|
||||||
self._current_profile_name = None
|
self._current_profile_name = None
|
||||||
self._empty_profile_active = False
|
|
||||||
self._empty_profile_consumed = False
|
|
||||||
self._pending_autosave_skip: tuple[str, str] | None = None
|
self._pending_autosave_skip: tuple[str, str] | None = None
|
||||||
self._exit_snapshot_written = False
|
self._exit_snapshot_written = False
|
||||||
self._refresh_workspace_list()
|
|
||||||
|
|
||||||
# State manager
|
# State manager
|
||||||
self.state_manager = WidgetStateManager(
|
self.state_manager = WidgetStateManager(
|
||||||
self, serialize_from_root=True, root_id="BECDockArea"
|
self, serialize_from_root=True, root_id="AdvancedDockArea"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Developer mode state
|
# Developer mode state
|
||||||
@@ -174,81 +176,83 @@ class BECDockArea(DockAreaWidget):
|
|||||||
# Initialize default editable state based on current lock
|
# Initialize default editable state based on current lock
|
||||||
self._set_editable(True) # default to editable; will sync toolbar toggle below
|
self._set_editable(True) # default to editable; will sync toolbar toggle below
|
||||||
|
|
||||||
|
if self._ensure_initial_profile():
|
||||||
|
self._refresh_workspace_list()
|
||||||
|
|
||||||
# Apply the requested mode after everything is set up
|
# Apply the requested mode after everything is set up
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
self._fetch_initial_profile()
|
if self._restore_initial_profile:
|
||||||
|
self._fetch_initial_profile()
|
||||||
|
|
||||||
@staticmethod
|
def _ensure_initial_profile(self) -> bool:
|
||||||
def _normalize_startup_profile(startup_profile: StartupProfile) -> StartupProfile:
|
|
||||||
"""
|
"""
|
||||||
Normalize startup profile values.
|
Ensure the "general" workspace profile always exists for the current namespace.
|
||||||
"""
|
The "general" profile is mandatory and will be recreated if deleted.
|
||||||
if startup_profile == "":
|
If list_profile fails due to file permission or corrupted profiles, no action taken.
|
||||||
return None
|
|
||||||
return startup_profile
|
|
||||||
|
|
||||||
def _resolve_restore_startup_profile(self) -> str | None:
|
Returns:
|
||||||
|
bool: True if a profile was created, False otherwise.
|
||||||
"""
|
"""
|
||||||
Resolve the profile name when startup profile is set to "restore".
|
|
||||||
"""
|
|
||||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
|
||||||
namespace = self.profile_namespace
|
namespace = self.profile_namespace
|
||||||
|
try:
|
||||||
|
existing_profiles = list_profiles(namespace)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive guard
|
||||||
|
logger.warning(f"Unable to enumerate profiles for namespace '{namespace}': {exc}")
|
||||||
|
return False
|
||||||
|
|
||||||
instance_id = self._last_profile_instance_id()
|
# Always ensure "general" profile exists
|
||||||
if instance_id:
|
name = "general"
|
||||||
inst_profile = get_last_profile(
|
if name in existing_profiles:
|
||||||
namespace=namespace, instance=instance_id, allow_namespace_fallback=False
|
return False
|
||||||
)
|
|
||||||
if inst_profile and self._profile_exists(inst_profile, namespace):
|
|
||||||
return inst_profile
|
|
||||||
|
|
||||||
last = get_last_profile(namespace=namespace)
|
logger.info(
|
||||||
if last and self._profile_exists(last, namespace):
|
f"Profile '{name}' not found in namespace '{namespace}'. Creating mandatory '{name}' workspace."
|
||||||
return last
|
)
|
||||||
|
|
||||||
combo_text = combo.currentText().strip()
|
self._write_profile_settings(name, namespace, save_preview=False)
|
||||||
if combo_text and self._profile_exists(combo_text, namespace):
|
set_quick_select(name, True, namespace=namespace)
|
||||||
return combo_text
|
set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id())
|
||||||
|
return True
|
||||||
return None
|
|
||||||
|
|
||||||
def _fetch_initial_profile(self):
|
def _fetch_initial_profile(self):
|
||||||
startup_profile = self._startup_profile
|
# Restore last-used profile if available; otherwise fall back to combo selection
|
||||||
|
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||||
|
namespace = self.profile_namespace
|
||||||
|
init_profile = None
|
||||||
|
|
||||||
if startup_profile == "skip":
|
# First priority: use init_profile if explicitly provided
|
||||||
logger.debug("Skipping startup profile initialization.")
|
if self._init_profile:
|
||||||
return
|
init_profile = self._init_profile
|
||||||
|
else:
|
||||||
if startup_profile == "restore":
|
# Try to restore from last used profile
|
||||||
restored = self._resolve_restore_startup_profile()
|
instance_id = self._last_profile_instance_id()
|
||||||
if restored:
|
if instance_id:
|
||||||
self._load_initial_profile(restored)
|
inst_profile = get_last_profile(
|
||||||
return
|
namespace=namespace, instance=instance_id, allow_namespace_fallback=False
|
||||||
self._start_empty_workspace()
|
)
|
||||||
return
|
if inst_profile and self._profile_exists(inst_profile, namespace):
|
||||||
|
init_profile = inst_profile
|
||||||
if startup_profile is None:
|
if not init_profile:
|
||||||
self._start_empty_workspace()
|
last = get_last_profile(namespace=namespace)
|
||||||
return
|
if last and self._profile_exists(last, namespace):
|
||||||
|
init_profile = last
|
||||||
self._load_initial_profile(startup_profile)
|
else:
|
||||||
|
text = combo.currentText()
|
||||||
|
init_profile = text if text else None
|
||||||
|
if not init_profile:
|
||||||
|
# Fall back to "general" profile which is guaranteed to exist
|
||||||
|
if self._profile_exists("general", namespace):
|
||||||
|
init_profile = "general"
|
||||||
|
if init_profile:
|
||||||
|
self._load_initial_profile(init_profile)
|
||||||
|
|
||||||
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, start_empty=self._start_empty)
|
||||||
if not self._empty_profile_active:
|
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||||
self._set_workspace_combo_text_silent(name)
|
combo.blockSignals(True)
|
||||||
|
combo.setCurrentText(name)
|
||||||
def _start_empty_workspace(self) -> None:
|
combo.blockSignals(False)
|
||||||
"""
|
|
||||||
Initialize the dock area in transient empty-profile mode.
|
|
||||||
"""
|
|
||||||
if (
|
|
||||||
getattr(self, "_current_profile_name", None) is None
|
|
||||||
and not self._empty_profile_consumed
|
|
||||||
):
|
|
||||||
self.delete_all()
|
|
||||||
self._enter_empty_profile_state()
|
|
||||||
|
|
||||||
def _customize_dock(self, dock: CDockWidget, widget: QWidget) -> None:
|
def _customize_dock(self, dock: CDockWidget, widget: QWidget) -> None:
|
||||||
prefs = getattr(dock, "_dock_preferences", {}) or {}
|
prefs = getattr(dock, "_dock_preferences", {}) or {}
|
||||||
@@ -299,7 +303,7 @@ class BECDockArea(DockAreaWidget):
|
|||||||
or a sequence of button names to hide.
|
or a sequence of button names to hide.
|
||||||
show_settings_action(bool | None): Control whether a dock settings/property action should
|
show_settings_action(bool | None): Control whether a dock settings/property action should
|
||||||
be installed. Defaults to ``False`` for the basic dock area; subclasses
|
be installed. Defaults to ``False`` for the basic dock area; subclasses
|
||||||
such as `BECDockArea` override the default to ``True``.
|
such as `AdvancedDockArea` override the default to ``True``.
|
||||||
promote_central(bool): When True, promote the created dock to be the dock manager's
|
promote_central(bool): When True, promote the created dock to be the dock manager's
|
||||||
central widget (useful for editor stacks or other root content).
|
central widget (useful for editor stacks or other root content).
|
||||||
object_name(str | None): Optional object name to assign to the created widget.
|
object_name(str | None): Optional object name to assign to the created widget.
|
||||||
@@ -370,11 +374,10 @@ class BECDockArea(DockAreaWidget):
|
|||||||
"Add Circular ProgressBar",
|
"Add Circular ProgressBar",
|
||||||
"RingProgressBar",
|
"RingProgressBar",
|
||||||
),
|
),
|
||||||
"terminal": (BecConsole.ICON_NAME, "Add Terminal", "BecConsole"),
|
"terminal": (WebConsole.ICON_NAME, "Add Terminal", "WebConsole"),
|
||||||
"bec_shell": (BECShell.ICON_NAME, "Add BEC Shell", "BECShell"),
|
"bec_shell": (BECShell.ICON_NAME, "Add BEC Shell", "BECShell"),
|
||||||
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"),
|
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"),
|
||||||
"sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"),
|
"sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"),
|
||||||
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel", "LogPanel"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create expandable menu actions (original behavior)
|
# Create expandable menu actions (original behavior)
|
||||||
@@ -399,26 +402,6 @@ 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)
|
||||||
|
|
||||||
# Build plugin widget menu (only shown when plugin widgets are available)
|
|
||||||
try: # TODO move this check to bec_plugin_helper.ser_widget_plugin method to fix globally
|
|
||||||
plugin_widgets_dict = get_all_plugin_widgets().as_dict()
|
|
||||||
except (ImportError, AttributeError, RuntimeError):
|
|
||||||
logger.warning("Failed to discover plugin widgets for toolbar menu.", exc_info=True)
|
|
||||||
plugin_widgets_dict = {}
|
|
||||||
plugin_actions: dict[str, tuple[str, str, str]] = {
|
|
||||||
widget_name: (
|
|
||||||
getattr(widget_cls, "ICON_NAME", "widgets"),
|
|
||||||
f"Add {widget_name}",
|
|
||||||
widget_name,
|
|
||||||
)
|
|
||||||
for widget_name, widget_cls in plugin_widgets_dict.items()
|
|
||||||
}
|
|
||||||
if plugin_actions:
|
|
||||||
_build_menu("menu_plugins", "Add Plugins ", plugin_actions)
|
|
||||||
logger.success(
|
|
||||||
"Plugin widgets added to toolbar menu: " + ", ".join(plugin_actions.keys())
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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)
|
||||||
@@ -489,16 +472,14 @@ class BECDockArea(DockAreaWidget):
|
|||||||
bda.add_action("dark_mode")
|
bda.add_action("dark_mode")
|
||||||
self.toolbar.add_bundle(bda)
|
self.toolbar.add_bundle(bda)
|
||||||
|
|
||||||
# Store mappings on self for use in _hook_toolbar and _apply_toolbar_layout
|
self._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):
|
||||||
@@ -508,7 +489,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
|
act = menu.actions[key].action
|
||||||
if key == "terminal":
|
if widget_type == "LogPanel":
|
||||||
|
act.setEnabled(False) # keep disabled per issue #644
|
||||||
|
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)
|
||||||
)
|
)
|
||||||
@@ -524,14 +507,15 @@ class BECDockArea(DockAreaWidget):
|
|||||||
_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
|
||||||
flat_action.triggered.connect(lambda _, t=widget_type: self.new(t))
|
if widget_type == "LogPanel":
|
||||||
|
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"])
|
||||||
@@ -616,6 +600,13 @@ class BECDockArea(DockAreaWidget):
|
|||||||
"""Namespace used to scope user/default profile files for this dock area."""
|
"""Namespace used to scope user/default profile files for this dock area."""
|
||||||
return self._resolve_profile_namespace()
|
return self._resolve_profile_namespace()
|
||||||
|
|
||||||
|
def _active_profile_name_or_default(self) -> str:
|
||||||
|
name = getattr(self, "_current_profile_name", None)
|
||||||
|
if not name:
|
||||||
|
name = "general"
|
||||||
|
self._current_profile_name = name
|
||||||
|
return name
|
||||||
|
|
||||||
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 user_profile_candidates(name, namespace)
|
||||||
@@ -683,34 +674,12 @@ class BECDockArea(DockAreaWidget):
|
|||||||
name: The profile name.
|
name: The profile name.
|
||||||
namespace: The profile namespace.
|
namespace: The profile namespace.
|
||||||
"""
|
"""
|
||||||
self._empty_profile_active = False
|
|
||||||
self._empty_profile_consumed = True
|
|
||||||
self._current_profile_name = name
|
self._current_profile_name = name
|
||||||
self.profile_changed.emit(name)
|
self.profile_changed.emit(name)
|
||||||
set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id())
|
set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id())
|
||||||
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:
|
|
||||||
"""
|
|
||||||
Switch to the transient empty workspace state.
|
|
||||||
|
|
||||||
In this mode there is no active profile name, the toolbar shows an
|
|
||||||
explicit blank profile entry, and no autosave on shutdown is performed.
|
|
||||||
"""
|
|
||||||
self._empty_profile_active = True
|
|
||||||
self._current_profile_name = None
|
|
||||||
self._pending_autosave_skip = None
|
|
||||||
self._refresh_workspace_list()
|
|
||||||
|
|
||||||
@SafeSlot()
|
@SafeSlot()
|
||||||
def list_profiles(self) -> list[str]:
|
def list_profiles(self) -> list[str]:
|
||||||
"""
|
"""
|
||||||
@@ -826,6 +795,7 @@ 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()
|
||||||
@@ -843,10 +813,10 @@ class BECDockArea(DockAreaWidget):
|
|||||||
"""
|
"""
|
||||||
self.save_profile(name, show_dialog=True)
|
self.save_profile(name, show_dialog=True)
|
||||||
|
|
||||||
@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, start_empty: bool = False):
|
||||||
"""
|
"""
|
||||||
Load a workspace profile.
|
Load a workspace profile.
|
||||||
|
|
||||||
@@ -855,10 +825,8 @@ class BECDockArea(DockAreaWidget):
|
|||||||
|
|
||||||
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.
|
||||||
|
start_empty (bool): If True, load a profile without any widgets. Danger of overwriting the dynamic state of that profile.
|
||||||
"""
|
"""
|
||||||
if name == "":
|
|
||||||
return
|
|
||||||
|
|
||||||
if not name: # Gui fallback if the name is not provided
|
if not name: # Gui fallback if the name is not provided
|
||||||
name, ok = QInputDialog.getText(
|
name, ok = QInputDialog.getText(
|
||||||
self, "Load Workspace", "Enter the name of the workspace profile to load:"
|
self, "Load Workspace", "Enter the name of the workspace profile to load:"
|
||||||
@@ -890,6 +858,10 @@ class BECDockArea(DockAreaWidget):
|
|||||||
# Clear existing docks and remove all widgets
|
# Clear existing docks and remove all widgets
|
||||||
self.delete_all()
|
self.delete_all()
|
||||||
|
|
||||||
|
if start_empty:
|
||||||
|
self._finalize_profile_change(name, namespace)
|
||||||
|
return
|
||||||
|
|
||||||
# Rebuild widgets and restore states
|
# Rebuild widgets and restore states
|
||||||
for item in read_manifest(settings):
|
for item in read_manifest(settings):
|
||||||
obj_name = item["object_name"]
|
obj_name = item["object_name"]
|
||||||
@@ -1035,36 +1007,25 @@ class BECDockArea(DockAreaWidget):
|
|||||||
"""
|
"""
|
||||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||||
active_profile = getattr(self, "_current_profile_name", None)
|
active_profile = getattr(self, "_current_profile_name", None)
|
||||||
empty_profile_active = bool(getattr(self, "_empty_profile_active", False))
|
|
||||||
namespace = self.profile_namespace
|
namespace = self.profile_namespace
|
||||||
if hasattr(combo, "set_quick_profile_provider"):
|
if hasattr(combo, "set_quick_profile_provider"):
|
||||||
combo.set_quick_profile_provider(lambda ns=namespace: list_quick_profiles(namespace=ns))
|
combo.set_quick_profile_provider(lambda ns=namespace: list_quick_profiles(namespace=ns))
|
||||||
if hasattr(combo, "refresh_profiles"):
|
if hasattr(combo, "refresh_profiles"):
|
||||||
if empty_profile_active:
|
combo.refresh_profiles(active_profile)
|
||||||
combo.refresh_profiles(active_profile, show_empty_profile=True)
|
|
||||||
else:
|
|
||||||
combo.refresh_profiles(active_profile)
|
|
||||||
else:
|
else:
|
||||||
# Fallback for regular QComboBox
|
# Fallback for regular QComboBox
|
||||||
combo.blockSignals(True)
|
combo.blockSignals(True)
|
||||||
combo.clear()
|
combo.clear()
|
||||||
quick_profiles = list_quick_profiles(namespace=namespace)
|
quick_profiles = list_quick_profiles(namespace=namespace)
|
||||||
items = [""] if empty_profile_active else []
|
items = list(quick_profiles)
|
||||||
items.extend(quick_profiles)
|
|
||||||
if active_profile and active_profile not in items:
|
if active_profile and active_profile not in items:
|
||||||
items.insert(0, active_profile)
|
items.insert(0, active_profile)
|
||||||
combo.addItems(items)
|
combo.addItems(items)
|
||||||
if empty_profile_active:
|
if active_profile:
|
||||||
idx = combo.findText("")
|
|
||||||
if idx >= 0:
|
|
||||||
combo.setCurrentIndex(idx)
|
|
||||||
elif active_profile:
|
|
||||||
idx = combo.findText(active_profile)
|
idx = combo.findText(active_profile)
|
||||||
if idx >= 0:
|
if idx >= 0:
|
||||||
combo.setCurrentIndex(idx)
|
combo.setCurrentIndex(idx)
|
||||||
if empty_profile_active:
|
if active_profile and active_profile not in quick_profiles:
|
||||||
combo.setToolTip("Unsaved empty workspace")
|
|
||||||
elif active_profile and active_profile not in quick_profiles:
|
|
||||||
combo.setToolTip("Active profile is not in quick select")
|
combo.setToolTip("Active profile is not in quick select")
|
||||||
else:
|
else:
|
||||||
combo.setToolTip("")
|
combo.setToolTip("")
|
||||||
@@ -1130,10 +1091,14 @@ 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 = ["menu_plots", "menu_devices", "menu_utils"]
|
bundles = [
|
||||||
if "menu_plugins" in getattr(self, "_ACTION_MAPPINGS", {}):
|
"menu_plots",
|
||||||
bundles.append("menu_plugins")
|
"menu_devices",
|
||||||
bundles += ["spacer_bundle", "workspace", "dock_actions"]
|
"menu_utils",
|
||||||
|
"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":
|
||||||
@@ -1165,16 +1130,7 @@ class BECDockArea(DockAreaWidget):
|
|||||||
logger.info("ADS prepare_for_shutdown: skipping (already handled or destroyed)")
|
logger.info("ADS prepare_for_shutdown: skipping (already handled or destroyed)")
|
||||||
return
|
return
|
||||||
|
|
||||||
if getattr(self, "_empty_profile_active", False):
|
name = self._active_profile_name_or_default()
|
||||||
logger.info("ADS prepare_for_shutdown: skipping autosave for unsaved empty workspace")
|
|
||||||
self._exit_snapshot_written = True
|
|
||||||
return
|
|
||||||
|
|
||||||
name = getattr(self, "_current_profile_name", None)
|
|
||||||
if not name:
|
|
||||||
logger.info("ADS prepare_for_shutdown: skipping autosave (no active profile)")
|
|
||||||
self._exit_snapshot_written = True
|
|
||||||
return
|
|
||||||
|
|
||||||
namespace = self.profile_namespace
|
namespace = self.profile_namespace
|
||||||
settings = open_user_settings(name, namespace=namespace)
|
settings = open_user_settings(name, namespace=namespace)
|
||||||
@@ -1182,33 +1138,6 @@ class BECDockArea(DockAreaWidget):
|
|||||||
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
|
||||||
|
|
||||||
def register_tour_steps(self, guided_tour, main_app):
|
|
||||||
"""Register Dock Area components with the guided tour.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
guided_tour: The GuidedTour instance to register with.
|
|
||||||
main_app: The main application instance (for accessing set_current).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ViewTourSteps | None: Model containing view title and step IDs.
|
|
||||||
"""
|
|
||||||
|
|
||||||
step_ids = []
|
|
||||||
|
|
||||||
# Register Dock Area toolbar
|
|
||||||
def get_dock_toolbar():
|
|
||||||
main_app.set_current("dock_area")
|
|
||||||
return (self.toolbar, None)
|
|
||||||
|
|
||||||
step_id = guided_tour.register_widget(
|
|
||||||
widget=get_dock_toolbar,
|
|
||||||
title="Dock Area Toolbar",
|
|
||||||
text="Use this toolbar to add widgets, manage workspaces, save and load profiles, and control the layout of your workspace.",
|
|
||||||
)
|
|
||||||
step_ids.append(step_id)
|
|
||||||
|
|
||||||
return ViewTourSteps(view_title="Dock Area Workspace", step_ids=step_ids)
|
|
||||||
|
|
||||||
def cleanup(self):
|
def cleanup(self):
|
||||||
"""
|
"""
|
||||||
Cleanup the dock area.
|
Cleanup the dock area.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Utilities for managing BECDockArea profiles stored in INI files.
|
Utilities for managing AdvancedDockArea 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: <base_path>/profiles/{default,user}
|
||||||
@@ -36,12 +36,12 @@ ProfileOrigin = Literal["module", "plugin", "settings", "unknown"]
|
|||||||
|
|
||||||
def module_profiles_dir() -> str:
|
def module_profiles_dir() -> str:
|
||||||
"""
|
"""
|
||||||
Return the built-in BECDockArea profiles directory bundled with the module.
|
Return the built-in AdvancedDockArea profiles directory bundled with the module.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Absolute path of the read-only module profiles directory.
|
str: Absolute path of the read-only module profiles directory.
|
||||||
"""
|
"""
|
||||||
return os.path.join(MODULE_PATH, "containers", "dock_area", "profiles")
|
return os.path.join(MODULE_PATH, "containers", "advanced_dock_area", "profiles")
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
@@ -115,12 +115,12 @@ def _settings_profiles_root() -> str:
|
|||||||
str: Absolute path to the profiles root. The directory is created if missing.
|
str: Absolute path to the profiles root. The directory is created if missing.
|
||||||
"""
|
"""
|
||||||
client = BECClient()
|
client = BECClient()
|
||||||
bec_widgets_settings = client._service_config.config.get("widgets_settings")
|
bec_widgets_settings = client._service_config.config.get("bec_widgets_settings")
|
||||||
bec_widgets_setting_path = (
|
bec_widgets_setting_path = (
|
||||||
bec_widgets_settings.get("base_path") if bec_widgets_settings else None
|
bec_widgets_settings.get("base_path") if bec_widgets_settings else None
|
||||||
)
|
)
|
||||||
default_path = os.path.join(bec_widgets_setting_path, "profiles")
|
default_path = os.path.join(bec_widgets_setting_path, "profiles")
|
||||||
root = os.path.expanduser(os.environ.get("BECWIDGETS_PROFILE_DIR", default_path))
|
root = os.environ.get("BECWIDGETS_PROFILE_DIR", default_path)
|
||||||
os.makedirs(root, exist_ok=True)
|
os.makedirs(root, exist_ok=True)
|
||||||
return root
|
return root
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ def _profiles_dir(segment: str, namespace: str | None) -> str:
|
|||||||
"""
|
"""
|
||||||
base = os.path.join(_settings_profiles_root(), segment)
|
base = os.path.join(_settings_profiles_root(), segment)
|
||||||
ns = slugify.slugify(namespace, separator="_") if namespace else None
|
ns = slugify.slugify(namespace, separator="_") if namespace else None
|
||||||
path = os.path.expanduser(os.path.join(base, ns) if ns else base)
|
path = os.path.join(base, ns) if ns else base
|
||||||
os.makedirs(path, exist_ok=True)
|
os.makedirs(path, exist_ok=True)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ class WorkSpaceManager(BECWidget, QWidget):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self.target_widget.save_profile_dialog()
|
self.target_widget.save_profile_dialog()
|
||||||
# BECDockArea will emit profile_changed which will trigger table refresh,
|
# AdvancedDockArea will emit profile_changed which will trigger table refresh,
|
||||||
# but ensure the UI stays in sync even if the signal is delayed.
|
# but ensure the UI stays in sync even if the signal is delayed.
|
||||||
self.render_table()
|
self.render_table()
|
||||||
current = getattr(self.target_widget, "_current_profile_name", None)
|
current = getattr(self.target_widget, "_current_profile_name", None)
|
||||||
|
|||||||
@@ -24,30 +24,24 @@ 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):
|
||||||
self, current_text: str, 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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
current_text = active_profile or self.currentText()
|
||||||
|
self.blockSignals(True)
|
||||||
self.clear()
|
self.clear()
|
||||||
|
|
||||||
quick_profiles = self._quick_provider()
|
quick_profiles = self._quick_provider()
|
||||||
quick_set = set(quick_profiles)
|
quick_set = set(quick_profiles)
|
||||||
|
|
||||||
items: list[str] = []
|
items = list(quick_profiles)
|
||||||
if show_empty_profile:
|
|
||||||
items.append("")
|
|
||||||
|
|
||||||
if active_profile and active_profile not in quick_set:
|
if active_profile and active_profile not in quick_set:
|
||||||
items.append(active_profile)
|
items.insert(0, active_profile)
|
||||||
|
|
||||||
for profile in quick_profiles:
|
|
||||||
if profile not in items:
|
|
||||||
items.append(profile)
|
|
||||||
|
|
||||||
if active_profile and active_profile not in quick_set:
|
|
||||||
# keep active profile at the top when not in quick list
|
|
||||||
items.remove(active_profile)
|
|
||||||
insert_pos = 1 if show_empty_profile else 0
|
|
||||||
items.insert(insert_pos, active_profile)
|
|
||||||
|
|
||||||
for profile in items:
|
for profile in items:
|
||||||
self.addItem(profile)
|
self.addItem(profile)
|
||||||
@@ -58,15 +52,6 @@ class ProfileComboBox(QComboBox):
|
|||||||
self.setItemData(idx, None, Qt.ItemDataRole.ToolTipRole)
|
self.setItemData(idx, None, Qt.ItemDataRole.ToolTipRole)
|
||||||
self.setItemData(idx, None, Qt.ItemDataRole.ForegroundRole)
|
self.setItemData(idx, None, Qt.ItemDataRole.ForegroundRole)
|
||||||
|
|
||||||
if profile == "":
|
|
||||||
self.setItemData(idx, "Unsaved empty workspace", Qt.ItemDataRole.ToolTipRole)
|
|
||||||
if active_profile is None:
|
|
||||||
font = QFont(self.font())
|
|
||||||
font.setItalic(True)
|
|
||||||
self.setItemData(idx, font, Qt.ItemDataRole.FontRole)
|
|
||||||
self.setCurrentIndex(idx)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if active_profile and profile == active_profile:
|
if active_profile and profile == active_profile:
|
||||||
tooltip = "Active workspace profile"
|
tooltip = "Active workspace profile"
|
||||||
if profile not in quick_set:
|
if profile not in quick_set:
|
||||||
@@ -84,52 +69,27 @@ class ProfileComboBox(QComboBox):
|
|||||||
self.setItemData(idx, "Not in quick select", Qt.ItemDataRole.ToolTipRole)
|
self.setItemData(idx, "Not in quick select", Qt.ItemDataRole.ToolTipRole)
|
||||||
|
|
||||||
# Restore selection if possible
|
# Restore selection if possible
|
||||||
if show_empty_profile and active_profile is None:
|
index = self.findText(current_text)
|
||||||
empty_idx = self.findText("")
|
if index >= 0:
|
||||||
if empty_idx >= 0:
|
self.setCurrentIndex(index)
|
||||||
self.setCurrentIndex(empty_idx)
|
|
||||||
else:
|
|
||||||
index = self.findText(current_text)
|
|
||||||
if index >= 0:
|
|
||||||
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:
|
||||||
self.setCurrentIndex(idx)
|
self.setCurrentIndex(idx)
|
||||||
if show_empty_profile and self.currentText() == "":
|
if active_profile and active_profile not in quick_set:
|
||||||
self.setToolTip("Unsaved empty workspace")
|
|
||||||
elif active_profile and active_profile not in quick_set:
|
|
||||||
self.setToolTip("Active profile is not in quick select")
|
self.setToolTip("Active profile is not in quick select")
|
||||||
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:
|
||||||
"""
|
"""
|
||||||
Creates a workspace toolbar bundle for BECDockArea.
|
Creates a workspace toolbar bundle for AdvancedDockArea.
|
||||||
|
|
||||||
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.
|
||||||
@@ -179,7 +139,7 @@ def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -
|
|||||||
|
|
||||||
class WorkspaceConnection(BundleConnection):
|
class WorkspaceConnection(BundleConnection):
|
||||||
"""
|
"""
|
||||||
Connection class for workspace actions in BECDockArea.
|
Connection class for workspace actions in AdvancedDockArea.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, components: ToolbarComponents, target_widget=None):
|
def __init__(self, components: ToolbarComponents, target_widget=None):
|
||||||
|
|||||||
@@ -101,12 +101,14 @@ class Explorer(BECWidget, QWidget):
|
|||||||
palette = get_theme_palette()
|
palette = get_theme_palette()
|
||||||
separator_color = palette.mid().color()
|
separator_color = palette.mid().color()
|
||||||
|
|
||||||
self.splitter.setStyleSheet(f"""
|
self.splitter.setStyleSheet(
|
||||||
|
f"""
|
||||||
QSplitter::handle {{
|
QSplitter::handle {{
|
||||||
height: 0.1px;
|
height: 0.1px;
|
||||||
background-color: rgba({separator_color.red()}, {separator_color.green()}, {separator_color.blue()}, 60);
|
background-color: rgba({separator_color.red()}, {separator_color.green()}, {separator_color.blue()}, 60);
|
||||||
}}
|
}}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
def _update_spacer(self) -> None:
|
def _update_spacer(self) -> None:
|
||||||
"""Update the spacer size based on section states"""
|
"""Update the spacer size based on section states"""
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class ScriptTreeWidget(QWidget):
|
|||||||
layout.setSpacing(0)
|
layout.setSpacing(0)
|
||||||
|
|
||||||
# Create tree view
|
# Create tree view
|
||||||
self.tree = QTreeView(parent=self)
|
self.tree = QTreeView()
|
||||||
self.tree.setHeaderHidden(True)
|
self.tree.setHeaderHidden(True)
|
||||||
self.tree.setRootIsDecorated(True)
|
self.tree.setRootIsDecorated(True)
|
||||||
|
|
||||||
@@ -71,12 +71,12 @@ class ScriptTreeWidget(QWidget):
|
|||||||
self.tree.setMouseTracking(True)
|
self.tree.setMouseTracking(True)
|
||||||
|
|
||||||
# Create file system model
|
# Create file system model
|
||||||
self.model = QFileSystemModel(parent=self)
|
self.model = QFileSystemModel()
|
||||||
self.model.setNameFilters(["*.py"])
|
self.model.setNameFilters(["*.py"])
|
||||||
self.model.setNameFilterDisables(False)
|
self.model.setNameFilterDisables(False)
|
||||||
|
|
||||||
# Create proxy model to filter out underscore directories
|
# Create proxy model to filter out underscore directories
|
||||||
self.proxy_model = QSortFilterProxyModel(parent=self)
|
self.proxy_model = QSortFilterProxyModel()
|
||||||
self.proxy_model.setFilterRegularExpression(QRegularExpression("^[^_].*"))
|
self.proxy_model.setFilterRegularExpression(QRegularExpression("^[^_].*"))
|
||||||
self.proxy_model.setSourceModel(self.model)
|
self.proxy_model.setSourceModel(self.model)
|
||||||
self.tree.setModel(self.proxy_model)
|
self.tree.setModel(self.proxy_model)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from qtpy.QtWidgets import (
|
|||||||
)
|
)
|
||||||
from typeguard import typechecked
|
from typeguard import typechecked
|
||||||
|
|
||||||
from bec_widgets.utils.rpc_widget_handler import widget_handler
|
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||||
|
|
||||||
|
|
||||||
class LayoutManagerWidget(QWidget):
|
class LayoutManagerWidget(QWidget):
|
||||||
|
|||||||
@@ -1,83 +1,27 @@
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from qtpy import QtGui, QtWidgets
|
|
||||||
from qtpy.QtCore import QPoint, Qt
|
from qtpy.QtCore import QPoint, Qt
|
||||||
from qtpy.QtWidgets import (
|
from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QProgressBar, QVBoxLayout, QWidget
|
||||||
QApplication,
|
|
||||||
QFrame,
|
|
||||||
QHBoxLayout,
|
|
||||||
QLabel,
|
|
||||||
QProgressBar,
|
|
||||||
QVBoxLayout,
|
|
||||||
QWidget,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class WidgetTooltip(QWidget):
|
class WidgetTooltip(QWidget):
|
||||||
"""Frameless, always-on-top window that behaves like a tooltip."""
|
"""Frameless, always-on-top window that behaves like a tooltip."""
|
||||||
|
|
||||||
def __init__(self, content: QWidget) -> None:
|
def __init__(self, content: QWidget) -> None:
|
||||||
super().__init__(
|
super().__init__(None, Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
|
||||||
None,
|
self.setAttribute(Qt.WA_ShowWithoutActivating)
|
||||||
Qt.WindowType.ToolTip
|
|
||||||
| Qt.WindowType.FramelessWindowHint
|
|
||||||
| Qt.WindowType.WindowStaysOnTopHint,
|
|
||||||
)
|
|
||||||
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
|
|
||||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
|
||||||
self.setMouseTracking(True)
|
self.setMouseTracking(True)
|
||||||
self.content = content
|
self.content = content
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
layout.setContentsMargins(14, 14, 14, 14)
|
layout.setContentsMargins(6, 6, 6, 6)
|
||||||
|
layout.addWidget(self.content)
|
||||||
self._card = QFrame(self)
|
|
||||||
self._card.setObjectName("WidgetTooltipCard")
|
|
||||||
card_layout = QVBoxLayout(self._card)
|
|
||||||
card_layout.setContentsMargins(12, 10, 12, 10)
|
|
||||||
card_layout.addWidget(self.content)
|
|
||||||
|
|
||||||
shadow = QtWidgets.QGraphicsDropShadowEffect(self._card)
|
|
||||||
shadow.setBlurRadius(18)
|
|
||||||
shadow.setOffset(0, 2)
|
|
||||||
shadow.setColor(QtGui.QColor(0, 0, 0, 140))
|
|
||||||
self._card.setGraphicsEffect(shadow)
|
|
||||||
|
|
||||||
layout.addWidget(self._card)
|
|
||||||
self.apply_theme()
|
|
||||||
self.adjustSize()
|
self.adjustSize()
|
||||||
|
|
||||||
def leaveEvent(self, _event) -> None:
|
def leaveEvent(self, _event) -> None:
|
||||||
self.hide()
|
self.hide()
|
||||||
|
|
||||||
def apply_theme(self) -> None:
|
|
||||||
palette = QApplication.palette()
|
|
||||||
base = palette.color(QtGui.QPalette.ColorRole.Base)
|
|
||||||
text = palette.color(QtGui.QPalette.ColorRole.Text)
|
|
||||||
border = palette.color(QtGui.QPalette.ColorRole.Mid)
|
|
||||||
background = QtGui.QColor(base)
|
|
||||||
background.setAlpha(242)
|
|
||||||
self._card.setStyleSheet(f"""
|
|
||||||
QFrame#WidgetTooltipCard {{
|
|
||||||
background: {background.name(QtGui.QColor.NameFormat.HexArgb)};
|
|
||||||
border: 1px solid {border.name()};
|
|
||||||
border-radius: 12px;
|
|
||||||
}}
|
|
||||||
QFrame#WidgetTooltipCard QLabel {{
|
|
||||||
color: {text.name()};
|
|
||||||
background: transparent;
|
|
||||||
}}
|
|
||||||
""")
|
|
||||||
|
|
||||||
def show_above(self, global_pos: QPoint, offset: int = 8) -> None:
|
def show_above(self, global_pos: QPoint, offset: int = 8) -> None:
|
||||||
"""
|
|
||||||
Show the tooltip above a global position, adjusting to stay within screen bounds.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
global_pos(QPoint): The global position to show above.
|
|
||||||
offset(int, optional): The vertical offset from the global position. Defaults to 8 pixels.
|
|
||||||
"""
|
|
||||||
self.apply_theme()
|
|
||||||
self.adjustSize()
|
self.adjustSize()
|
||||||
screen = QApplication.screenAt(global_pos) or QApplication.primaryScreen()
|
screen = QApplication.screenAt(global_pos) or QApplication.primaryScreen()
|
||||||
screen_geo = screen.availableGeometry()
|
screen_geo = screen.availableGeometry()
|
||||||
@@ -86,43 +30,11 @@ class WidgetTooltip(QWidget):
|
|||||||
x = global_pos.x() - geom.width() // 2
|
x = global_pos.x() - geom.width() // 2
|
||||||
y = global_pos.y() - geom.height() - offset
|
y = global_pos.y() - geom.height() - offset
|
||||||
|
|
||||||
self._navigate_screen_coordinates(screen_geo, geom, x, y)
|
|
||||||
|
|
||||||
def show_near(self, global_pos: QPoint, offset: QPoint | None = None) -> None:
|
|
||||||
"""
|
|
||||||
Show the tooltip near a global position, adjusting to stay within screen bounds.
|
|
||||||
By default, it will try to show below and to the right of the position,
|
|
||||||
but if that would cause it to go off-screen, it will flip to the other side.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
global_pos(QPoint): The global position to show near.
|
|
||||||
offset(QPoint, optional): The offset from the global position. Defaults to QPoint(12, 16).
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.apply_theme()
|
|
||||||
self.adjustSize()
|
|
||||||
offset = offset or QPoint(12, 16)
|
|
||||||
screen = QApplication.screenAt(global_pos) or QApplication.primaryScreen()
|
|
||||||
screen_geo = screen.availableGeometry()
|
|
||||||
geom = self.geometry()
|
|
||||||
|
|
||||||
x = global_pos.x() + offset.x()
|
|
||||||
y = global_pos.y() + offset.y()
|
|
||||||
|
|
||||||
if x + geom.width() > screen_geo.right():
|
|
||||||
x = global_pos.x() - geom.width() - abs(offset.x())
|
|
||||||
if y + geom.height() > screen_geo.bottom():
|
|
||||||
y = global_pos.y() - geom.height() - abs(offset.y())
|
|
||||||
|
|
||||||
self._navigate_screen_coordinates(screen_geo, geom, x, y)
|
|
||||||
|
|
||||||
def _navigate_screen_coordinates(self, screen_geo, geom, x, y):
|
|
||||||
x = max(screen_geo.left(), min(x, screen_geo.right() - geom.width()))
|
x = max(screen_geo.left(), min(x, screen_geo.right() - geom.width()))
|
||||||
y = max(screen_geo.top(), min(y, screen_geo.bottom() - geom.height()))
|
y = max(screen_geo.top(), min(y, screen_geo.bottom() - geom.height()))
|
||||||
|
|
||||||
self.move(x, y)
|
self.move(x, y)
|
||||||
self.show()
|
self.show()
|
||||||
self.raise_()
|
|
||||||
|
|
||||||
|
|
||||||
class HoverWidget(QWidget):
|
class HoverWidget(QWidget):
|
||||||
|
|||||||
+29
-15
@@ -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.bec_connector import BECConnector
|
from bec_widgets.utils 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
|
||||||
|
|
||||||
@@ -134,13 +134,15 @@ class NotificationToast(QFrame):
|
|||||||
bg.setAlphaF(0.30)
|
bg.setAlphaF(0.30)
|
||||||
icon_bg = bg.name(QtGui.QColor.HexArgb)
|
icon_bg = bg.name(QtGui.QColor.HexArgb)
|
||||||
icon_btn.setFixedSize(40, 40)
|
icon_btn.setFixedSize(40, 40)
|
||||||
icon_btn.setStyleSheet(f"""
|
icon_btn.setStyleSheet(
|
||||||
|
f"""
|
||||||
QToolButton {{
|
QToolButton {{
|
||||||
background: {icon_bg};
|
background: {icon_bg};
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 20px; /* perfect circle */
|
border-radius: 20px; /* perfect circle */
|
||||||
}}
|
}}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
title_lbl = QtWidgets.QLabel(self._title)
|
title_lbl = QtWidgets.QLabel(self._title)
|
||||||
|
|
||||||
@@ -325,13 +327,15 @@ class NotificationToast(QFrame):
|
|||||||
bg = QtGui.QColor(SEVERITY[value.value]["color"])
|
bg = QtGui.QColor(SEVERITY[value.value]["color"])
|
||||||
bg.setAlphaF(0.30)
|
bg.setAlphaF(0.30)
|
||||||
icon_bg = bg.name(QtGui.QColor.HexArgb)
|
icon_bg = bg.name(QtGui.QColor.HexArgb)
|
||||||
self._icon_btn.setStyleSheet(f"""
|
self._icon_btn.setStyleSheet(
|
||||||
|
f"""
|
||||||
QToolButton {{
|
QToolButton {{
|
||||||
background: {icon_bg};
|
background: {icon_bg};
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
}}
|
}}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
self.apply_theme(self._theme)
|
self.apply_theme(self._theme)
|
||||||
# keep injected gradient in sync
|
# keep injected gradient in sync
|
||||||
if getattr(self, "_hg_enabled", False):
|
if getattr(self, "_hg_enabled", False):
|
||||||
@@ -387,7 +391,8 @@ class NotificationToast(QFrame):
|
|||||||
card_bg.setAlphaF(0.88)
|
card_bg.setAlphaF(0.88)
|
||||||
btn_hover = self._accent_color.name()
|
btn_hover = self._accent_color.name()
|
||||||
|
|
||||||
self.setStyleSheet(f"""
|
self.setStyleSheet(
|
||||||
|
f"""
|
||||||
#NotificationToast {{
|
#NotificationToast {{
|
||||||
background: {card_bg.name(QtGui.QColor.HexArgb)};
|
background: {card_bg.name(QtGui.QColor.HexArgb)};
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -401,15 +406,18 @@ class NotificationToast(QFrame):
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}}
|
}}
|
||||||
#NotificationToast QPushButton:hover {{ color: {btn_hover}; }}
|
#NotificationToast QPushButton:hover {{ color: {btn_hover}; }}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
# traceback panel colours
|
# traceback panel colours
|
||||||
trace_bg = "#1e1e1e" if theme == "dark" else "#f0f0f0"
|
trace_bg = "#1e1e1e" if theme == "dark" else "#f0f0f0"
|
||||||
self.trace_view.setStyleSheet(f"""
|
self.trace_view.setStyleSheet(
|
||||||
|
f"""
|
||||||
background:{trace_bg};
|
background:{trace_bg};
|
||||||
color:{palette['body']};
|
color:{palette['body']};
|
||||||
border:none;
|
border:none;
|
||||||
border-radius:8px;
|
border-radius:8px;
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
# icon glyph vs badge background: darker badge, lighter icon in light mode
|
# icon glyph vs badge background: darker badge, lighter icon in light mode
|
||||||
icon_fg = "#ffffff" if theme == "light" else self._accent_color.name()
|
icon_fg = "#ffffff" if theme == "light" else self._accent_color.name()
|
||||||
@@ -430,13 +438,15 @@ class NotificationToast(QFrame):
|
|||||||
else:
|
else:
|
||||||
badge_bg.setAlphaF(0.30)
|
badge_bg.setAlphaF(0.30)
|
||||||
icon_bg = badge_bg.name(QtGui.QColor.HexArgb)
|
icon_bg = badge_bg.name(QtGui.QColor.HexArgb)
|
||||||
self._icon_btn.setStyleSheet(f"""
|
self._icon_btn.setStyleSheet(
|
||||||
|
f"""
|
||||||
QToolButton {{
|
QToolButton {{
|
||||||
background: {icon_bg};
|
background: {icon_bg};
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
}}
|
}}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
# stronger accent wash in light mode, slightly stronger in dark too
|
# stronger accent wash in light mode, slightly stronger in dark too
|
||||||
self._accent_alpha = 110 if theme == "light" else 60
|
self._accent_alpha = 110 if theme == "light" else 60
|
||||||
@@ -583,7 +593,8 @@ class NotificationCentre(QScrollArea):
|
|||||||
self.setWidgetResizable(True)
|
self.setWidgetResizable(True)
|
||||||
# transparent background so only the toast cards are visible
|
# transparent background so only the toast cards are visible
|
||||||
self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True)
|
self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True)
|
||||||
self.setStyleSheet("""
|
self.setStyleSheet(
|
||||||
|
"""
|
||||||
#NotificationCentre { background: transparent; }
|
#NotificationCentre { background: transparent; }
|
||||||
#NotificationCentre QScrollBar:vertical {
|
#NotificationCentre QScrollBar:vertical {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -599,7 +610,8 @@ class NotificationCentre(QScrollArea):
|
|||||||
#NotificationCentre QScrollBar::sub-line:vertical { height: 0; }
|
#NotificationCentre QScrollBar::sub-line:vertical { height: 0; }
|
||||||
#NotificationCentre QScrollBar::add-page:vertical,
|
#NotificationCentre QScrollBar::add-page:vertical,
|
||||||
#NotificationCentre QScrollBar::sub-page:vertical { background: transparent; }
|
#NotificationCentre QScrollBar::sub-page:vertical { background: transparent; }
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
|
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
|
||||||
self.setFrameShape(QtWidgets.QFrame.NoFrame)
|
self.setFrameShape(QtWidgets.QFrame.NoFrame)
|
||||||
self.setFixedWidth(fixed_width)
|
self.setFixedWidth(fixed_width)
|
||||||
@@ -946,7 +958,8 @@ class NotificationIndicator(QWidget):
|
|||||||
self._group.buttonToggled.connect(self._button_toggled)
|
self._group.buttonToggled.connect(self._button_toggled)
|
||||||
|
|
||||||
# minimalistic look: no frames or backgrounds on the buttons
|
# minimalistic look: no frames or backgrounds on the buttons
|
||||||
self.setStyleSheet("""
|
self.setStyleSheet(
|
||||||
|
"""
|
||||||
QToolButton {
|
QToolButton {
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -957,7 +970,8 @@ class NotificationIndicator(QWidget):
|
|||||||
background: rgba(255, 255, 255, 40);
|
background: rgba(255, 255, 255, 40);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
# initial state: none checked (auto‑dismiss behaviour)
|
# initial state: none checked (auto‑dismiss behaviour)
|
||||||
for k in kinds:
|
for k in kinds:
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from bec_lib import bec_logger
|
|
||||||
from bec_lib.endpoints import MessageEndpoints
|
from bec_lib.endpoints import MessageEndpoints
|
||||||
from qtpy.QtCore import QEvent, QSize, Qt, QTimer
|
from qtpy.QtCore import QEvent, QSize, Qt, QTimer
|
||||||
from qtpy.QtGui import QAction, QActionGroup, QIcon
|
from qtpy.QtGui import QAction, QActionGroup, QIcon
|
||||||
@@ -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,
|
||||||
@@ -31,17 +31,12 @@ from bec_widgets.widgets.containers.main_window.addons.notification_center.notif
|
|||||||
from bec_widgets.widgets.containers.main_window.addons.scroll_label import ScrollLabel
|
from bec_widgets.widgets.containers.main_window.addons.scroll_label import ScrollLabel
|
||||||
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
|
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
|
||||||
from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import ScanProgressBar
|
from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import ScanProgressBar
|
||||||
from bec_widgets.widgets.utility.widget_hierarchy_tree.widget_hierarchy_tree import (
|
|
||||||
WidgetHierarchyDialog,
|
|
||||||
)
|
|
||||||
|
|
||||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||||
|
|
||||||
# Ensure the application does not use the native menu bar on macOS to be consistent with linux development.
|
# Ensure the application does not use the native menu bar on macOS to be consistent with linux development.
|
||||||
QApplication.setAttribute(Qt.ApplicationAttribute.AA_DontUseNativeMenuBar, True)
|
QApplication.setAttribute(Qt.ApplicationAttribute.AA_DontUseNativeMenuBar, True)
|
||||||
|
|
||||||
logger = bec_logger.logger
|
|
||||||
|
|
||||||
|
|
||||||
class BECMainWindow(BECWidget, QMainWindow):
|
class BECMainWindow(BECWidget, QMainWindow):
|
||||||
RPC = True
|
RPC = True
|
||||||
@@ -54,7 +49,6 @@ class BECMainWindow(BECWidget, QMainWindow):
|
|||||||
|
|
||||||
self.app = QApplication.instance()
|
self.app = QApplication.instance()
|
||||||
self.status_bar = self.statusBar()
|
self.status_bar = self.statusBar()
|
||||||
self._launcher_window = None
|
|
||||||
self.setWindowTitle(window_title)
|
self.setWindowTitle(window_title)
|
||||||
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
||||||
|
|
||||||
@@ -63,7 +57,6 @@ class BECMainWindow(BECWidget, QMainWindow):
|
|||||||
self.notification_broker = BECNotificationBroker(parent=self)
|
self.notification_broker = BECNotificationBroker(parent=self)
|
||||||
self._nc_margin = 16
|
self._nc_margin = 16
|
||||||
self._position_notification_centre()
|
self._position_notification_centre()
|
||||||
self._widget_hierarchy_dialog: WidgetHierarchyDialog | None = None
|
|
||||||
|
|
||||||
# Init ui
|
# Init ui
|
||||||
self._init_ui()
|
self._init_ui()
|
||||||
@@ -196,18 +189,14 @@ class BECMainWindow(BECWidget, QMainWindow):
|
|||||||
def _add_scan_progress_bar(self):
|
def _add_scan_progress_bar(self):
|
||||||
|
|
||||||
# Setting HoverWidget for the scan progress bar - minimal and full version
|
# Setting HoverWidget for the scan progress bar - minimal and full version
|
||||||
self._scan_progress_bar_simple = ScanProgressBar(
|
self._scan_progress_bar_simple = ScanProgressBar(self, one_line_design=True)
|
||||||
self, one_line_design=True, rpc_exposed=False, rpc_passthrough_children=False
|
|
||||||
)
|
|
||||||
self._scan_progress_bar_simple.show_elapsed_time = False
|
self._scan_progress_bar_simple.show_elapsed_time = False
|
||||||
self._scan_progress_bar_simple.show_remaining_time = False
|
self._scan_progress_bar_simple.show_remaining_time = False
|
||||||
self._scan_progress_bar_simple.show_source_label = False
|
self._scan_progress_bar_simple.show_source_label = False
|
||||||
self._scan_progress_bar_simple.progressbar.label_template = ""
|
self._scan_progress_bar_simple.progressbar.label_template = ""
|
||||||
self._scan_progress_bar_simple.progressbar.setFixedHeight(self.SCAN_PROGRESS_HEIGHT)
|
self._scan_progress_bar_simple.progressbar.setFixedHeight(self.SCAN_PROGRESS_HEIGHT)
|
||||||
self._scan_progress_bar_simple.progressbar.setFixedWidth(self.SCAN_PROGRESS_WIDTH)
|
self._scan_progress_bar_simple.progressbar.setFixedWidth(self.SCAN_PROGRESS_WIDTH)
|
||||||
self._scan_progress_bar_full = ScanProgressBar(
|
self._scan_progress_bar_full = ScanProgressBar(self)
|
||||||
self, rpc_exposed=False, rpc_passthrough_children=False
|
|
||||||
)
|
|
||||||
self._scan_progress_hover = HoverWidget(
|
self._scan_progress_hover = HoverWidget(
|
||||||
self, simple=self._scan_progress_bar_simple, full=self._scan_progress_bar_full
|
self, simple=self._scan_progress_bar_simple, full=self._scan_progress_bar_full
|
||||||
)
|
)
|
||||||
@@ -265,7 +254,7 @@ class BECMainWindow(BECWidget, QMainWindow):
|
|||||||
self.ui = loader.loader(ui_file)
|
self.ui = loader.loader(ui_file)
|
||||||
self.setCentralWidget(self.ui)
|
self.setCentralWidget(self.ui)
|
||||||
|
|
||||||
def fetch_theme(self) -> str:
|
def _fetch_theme(self) -> str:
|
||||||
return self.app.theme.theme
|
return self.app.theme.theme
|
||||||
|
|
||||||
def _get_launcher_from_qapp(self):
|
def _get_launcher_from_qapp(self):
|
||||||
@@ -286,16 +275,6 @@ class BECMainWindow(BECWidget, QMainWindow):
|
|||||||
Show the launcher if it exists.
|
Show the launcher if it exists.
|
||||||
"""
|
"""
|
||||||
launcher = self._get_launcher_from_qapp()
|
launcher = self._get_launcher_from_qapp()
|
||||||
if launcher is None:
|
|
||||||
from bec_widgets.applications.launch_window import LaunchWindow
|
|
||||||
|
|
||||||
cli_server = getattr(self.bec_dispatcher, "cli_server", None)
|
|
||||||
if cli_server is None:
|
|
||||||
logger.warning("Cannot open launcher: CLI server is not available.")
|
|
||||||
return
|
|
||||||
launcher = LaunchWindow(gui_id=f"{cli_server.gui_id}:launcher")
|
|
||||||
launcher.setAttribute(Qt.WA_ShowWithoutActivating) # type: ignore[arg-type]
|
|
||||||
self._launcher_window = launcher
|
|
||||||
if launcher:
|
if launcher:
|
||||||
launcher.show()
|
launcher.show()
|
||||||
launcher.activateWindow()
|
launcher.activateWindow()
|
||||||
@@ -333,11 +312,6 @@ class BECMainWindow(BECWidget, QMainWindow):
|
|||||||
light_theme_action.triggered.connect(lambda: self.change_theme("light"))
|
light_theme_action.triggered.connect(lambda: self.change_theme("light"))
|
||||||
dark_theme_action.triggered.connect(lambda: self.change_theme("dark"))
|
dark_theme_action.triggered.connect(lambda: self.change_theme("dark"))
|
||||||
|
|
||||||
theme_menu.addSeparator()
|
|
||||||
widget_tree_action = QAction("Show Widget Hierarchy", self)
|
|
||||||
widget_tree_action.triggered.connect(self._show_widget_hierarchy_dialog)
|
|
||||||
theme_menu.addAction(widget_tree_action)
|
|
||||||
|
|
||||||
# Set the default theme
|
# Set the default theme
|
||||||
if hasattr(self.app, "theme") and self.app.theme:
|
if hasattr(self.app, "theme") and self.app.theme:
|
||||||
theme_name = self.app.theme.theme.lower()
|
theme_name = self.app.theme.theme.lower()
|
||||||
@@ -421,23 +395,7 @@ class BECMainWindow(BECWidget, QMainWindow):
|
|||||||
return True
|
return True
|
||||||
return super().event(event)
|
return super().event(event)
|
||||||
|
|
||||||
def _show_widget_hierarchy_dialog(self):
|
|
||||||
if self._widget_hierarchy_dialog is None:
|
|
||||||
dialog = WidgetHierarchyDialog(root_widget=None, parent=self)
|
|
||||||
dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
|
||||||
dialog.destroyed.connect(lambda: setattr(self, "_widget_hierarchy_dialog", None))
|
|
||||||
self._widget_hierarchy_dialog = dialog
|
|
||||||
self._widget_hierarchy_dialog.refresh()
|
|
||||||
self._widget_hierarchy_dialog.show()
|
|
||||||
self._widget_hierarchy_dialog.raise_()
|
|
||||||
self._widget_hierarchy_dialog.activateWindow()
|
|
||||||
|
|
||||||
def cleanup(self):
|
def cleanup(self):
|
||||||
# Widget hierarchy dialog cleanup
|
|
||||||
if self._widget_hierarchy_dialog is not None:
|
|
||||||
self._widget_hierarchy_dialog.close()
|
|
||||||
self._widget_hierarchy_dialog = None
|
|
||||||
|
|
||||||
# Timer cleanup
|
# Timer cleanup
|
||||||
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
|
if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive():
|
||||||
self._client_info_expire_timer.stop()
|
self._client_info_expire_timer.stop()
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .positioner_box_base import PositionerBoxBase
|
||||||
|
|
||||||
|
__ALL__ = ["PositionerBoxBase"]
|
||||||
+11
-12
@@ -14,10 +14,10 @@ from qtpy.QtWidgets import (
|
|||||||
QLineEdit,
|
QLineEdit,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
|
from bec_widgets.utils.compact_popup import CompactPopupWidget
|
||||||
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,
|
||||||
)
|
)
|
||||||
@@ -43,7 +43,7 @@ class DeviceUpdateUIComponents(TypedDict):
|
|||||||
units: QLabel
|
units: QLabel
|
||||||
|
|
||||||
|
|
||||||
class PositionerBoxBase(BECWidget, QWidget):
|
class PositionerBoxBase(BECWidget, CompactPopupWidget):
|
||||||
"""Contains some core logic for positioner box widgets"""
|
"""Contains some core logic for positioner box widgets"""
|
||||||
|
|
||||||
current_path = ""
|
current_path = ""
|
||||||
@@ -57,10 +57,7 @@ class PositionerBoxBase(BECWidget, QWidget):
|
|||||||
parent: The parent widget.
|
parent: The parent widget.
|
||||||
device (Positioner): The device to control.
|
device (Positioner): The device to control.
|
||||||
"""
|
"""
|
||||||
super().__init__(parent=parent, **kwargs)
|
super().__init__(parent=parent, layout=QVBoxLayout, **kwargs)
|
||||||
self.main_layout = QVBoxLayout(self)
|
|
||||||
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
|
||||||
self.main_layout.setSpacing(0)
|
|
||||||
self._dialog = None
|
self._dialog = None
|
||||||
self.get_bec_shortcuts()
|
self.get_bec_shortcuts()
|
||||||
|
|
||||||
@@ -126,7 +123,7 @@ class PositionerBoxBase(BECWidget, QWidget):
|
|||||||
queue="emergency",
|
queue="emergency",
|
||||||
metadata={"RID": request_id, "response": False},
|
metadata={"RID": request_id, "response": False},
|
||||||
)
|
)
|
||||||
self.client.connector.send(MessageEndpoints.scan_queue_request(self.client.username), msg)
|
self.client.connector.send(MessageEndpoints.scan_queue_request(), msg)
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def _on_device_readback(
|
def _on_device_readback(
|
||||||
@@ -176,9 +173,11 @@ class PositionerBoxBase(BECWidget, QWidget):
|
|||||||
if is_moving:
|
if is_moving:
|
||||||
spinner.start()
|
spinner.start()
|
||||||
spinner.setToolTip("Device is moving")
|
spinner.setToolTip("Device is moving")
|
||||||
|
self.set_global_state("warning")
|
||||||
else:
|
else:
|
||||||
spinner.stop()
|
spinner.stop()
|
||||||
spinner.setToolTip("Device is idle")
|
spinner.setToolTip("Device is idle")
|
||||||
|
self.set_global_state("success")
|
||||||
else:
|
else:
|
||||||
spinner.setVisible(False)
|
spinner.setVisible(False)
|
||||||
|
|
||||||
@@ -197,8 +196,9 @@ class PositionerBoxBase(BECWidget, QWidget):
|
|||||||
pos = (readback_val - limits[0]) / (limits[1] - limits[0])
|
pos = (readback_val - limits[0]) / (limits[1] - limits[0])
|
||||||
position_indicator.set_value(pos)
|
position_indicator.set_value(pos)
|
||||||
|
|
||||||
@staticmethod
|
def _update_limits_ui(
|
||||||
def _update_limits_ui(limits: tuple[float, float], position_indicator, setpoint_validator):
|
self, limits: tuple[float, float], position_indicator, setpoint_validator
|
||||||
|
):
|
||||||
if limits is not None and limits[0] != limits[1]:
|
if limits is not None and limits[0] != limits[1]:
|
||||||
position_indicator.setToolTip(f"Min: {limits[0]}, Max: {limits[1]}")
|
position_indicator.setToolTip(f"Min: {limits[0]}, Max: {limits[1]}")
|
||||||
setpoint_validator.setRange(limits[0], limits[1])
|
setpoint_validator.setRange(limits[0], limits[1])
|
||||||
@@ -223,9 +223,8 @@ class PositionerBoxBase(BECWidget, QWidget):
|
|||||||
self.bec_dispatcher.disconnect_slot(slot, MessageEndpoints.device_readback(old_device))
|
self.bec_dispatcher.disconnect_slot(slot, MessageEndpoints.device_readback(old_device))
|
||||||
self.bec_dispatcher.connect_slot(slot, MessageEndpoints.device_readback(new_device))
|
self.bec_dispatcher.connect_slot(slot, MessageEndpoints.device_readback(new_device))
|
||||||
|
|
||||||
@staticmethod
|
def _toggle_enable_buttons(self, ui: DeviceUpdateUIComponents, enable: bool) -> None:
|
||||||
def _toggle_enable_buttons(ui: DeviceUpdateUIComponents, enable: bool) -> None:
|
"""Toogle enable/disable on available buttons
|
||||||
"""Toggle enable/disable on available buttons
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
enable (bool): Enable buttons
|
enable (bool): Enable buttons
|
||||||
+9
-7
@@ -11,12 +11,12 @@ 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._base import PositionerBoxBase
|
||||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base import (
|
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
|
||||||
DeviceUpdateUIComponents,
|
DeviceUpdateUIComponents,
|
||||||
PositionerBoxBase,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
@@ -63,10 +63,10 @@ class PositionerBox(PositionerBoxBase):
|
|||||||
|
|
||||||
self.ui = UILoader(self).loader(os.path.join(self.current_path, self.ui_file))
|
self.ui = UILoader(self).loader(os.path.join(self.current_path, self.ui_file))
|
||||||
|
|
||||||
self.main_layout.addWidget(self.ui)
|
self.addWidget(self.ui)
|
||||||
self.main_layout.setSpacing(0)
|
self.layout.setSpacing(0)
|
||||||
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||||
self.main_layout.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignHCenter)
|
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignHCenter)
|
||||||
ui_min_size = self.ui.minimumSize()
|
ui_min_size = self.ui.minimumSize()
|
||||||
ui_min_hint = self.ui.minimumSizeHint()
|
ui_min_hint = self.ui.minimumSizeHint()
|
||||||
self.setMinimumSize(
|
self.setMinimumSize(
|
||||||
@@ -115,6 +115,8 @@ class PositionerBox(PositionerBoxBase):
|
|||||||
return
|
return
|
||||||
old_device = self._device
|
old_device = self._device
|
||||||
self._device = value
|
self._device = value
|
||||||
|
if not self.label:
|
||||||
|
self.label = value
|
||||||
self.device_changed.emit(old_device, value)
|
self.device_changed.emit(old_device, value)
|
||||||
|
|
||||||
@SafeProperty(bool)
|
@SafeProperty(bool)
|
||||||
|
|||||||
+8
-6
@@ -12,12 +12,12 @@ 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._base import PositionerBoxBase
|
||||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_base import (
|
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
|
||||||
DeviceUpdateUIComponents,
|
DeviceUpdateUIComponents,
|
||||||
PositionerBoxBase,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
@@ -96,9 +96,9 @@ class PositionerBox2D(PositionerBoxBase):
|
|||||||
|
|
||||||
def connect_ui(self):
|
def connect_ui(self):
|
||||||
"""Connect the UI components to signals, data, or routines"""
|
"""Connect the UI components to signals, data, or routines"""
|
||||||
self.main_layout.addWidget(self.ui)
|
self.addWidget(self.ui)
|
||||||
self.main_layout.setSpacing(0)
|
self.layout.setSpacing(0)
|
||||||
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
||||||
def _init_ui(val: QDoubleValidator, device_id: DeviceId):
|
def _init_ui(val: QDoubleValidator, device_id: DeviceId):
|
||||||
ui = self._device_ui_components_hv(device_id)
|
ui = self._device_ui_components_hv(device_id)
|
||||||
@@ -200,6 +200,7 @@ class PositionerBox2D(PositionerBoxBase):
|
|||||||
return
|
return
|
||||||
old_device = self._device_hor
|
old_device = self._device_hor
|
||||||
self._device_hor = value
|
self._device_hor = value
|
||||||
|
self.label = f"{self._device_hor}, {self._device_ver}"
|
||||||
self.device_changed_hor.emit(old_device, value)
|
self.device_changed_hor.emit(old_device, value)
|
||||||
self._init_device(self.device_hor, self.position_update_hor.emit, self.update_limits_hor)
|
self._init_device(self.device_hor, self.position_update_hor.emit, self.update_limits_hor)
|
||||||
|
|
||||||
@@ -219,6 +220,7 @@ class PositionerBox2D(PositionerBoxBase):
|
|||||||
return
|
return
|
||||||
old_device = self._device_ver
|
old_device = self._device_ver
|
||||||
self._device_ver = value
|
self._device_ver = value
|
||||||
|
self.label = f"{self._device_hor}, {self._device_ver}"
|
||||||
self.device_changed_ver.emit(old_device, value)
|
self.device_changed_ver.emit(old_device, value)
|
||||||
self._init_device(self.device_ver, self.position_update_ver.emit, self.update_limits_ver)
|
self._init_device(self.device_ver, self.position_update_ver.emit, self.update_limits_ver)
|
||||||
|
|
||||||
|
|||||||
-77
@@ -1,8 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from bec_lib.device import Positioner
|
from bec_lib.device import Positioner
|
||||||
from qtpy.QtCore import Qt
|
|
||||||
from qtpy.QtWidgets import QSizePolicy
|
|
||||||
|
|
||||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
|
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
|
||||||
|
|
||||||
@@ -24,82 +22,7 @@ class PositionerControlLine(PositionerBox):
|
|||||||
device (Positioner): The device to control.
|
device (Positioner): The device to control.
|
||||||
"""
|
"""
|
||||||
self.current_path = os.path.dirname(__file__)
|
self.current_path = os.path.dirname(__file__)
|
||||||
self._indicator_switch_width = 0
|
|
||||||
self._horizontal_indicator_width = 0
|
|
||||||
self._vertical_indicator_width = 15
|
|
||||||
self._indicator_thickness = 10
|
|
||||||
self._indicator_is_horizontal = False
|
|
||||||
self._line_height = self.dimensions[0]
|
|
||||||
super().__init__(parent=parent, device=device, *args, **kwargs)
|
super().__init__(parent=parent, device=device, *args, **kwargs)
|
||||||
self._configure_line_layout()
|
|
||||||
self._update_indicator_orientation()
|
|
||||||
|
|
||||||
def _configure_line_layout(self):
|
|
||||||
device_box = self.ui.device_box
|
|
||||||
indicator = self.ui.position_indicator
|
|
||||||
|
|
||||||
self.main_layout.setAlignment(Qt.AlignmentFlag(0))
|
|
||||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
|
||||||
self.ui.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
|
||||||
device_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
|
||||||
|
|
||||||
self._line_height = max(
|
|
||||||
self.dimensions[0],
|
|
||||||
self.ui.minimumSizeHint().height(),
|
|
||||||
self.ui.sizeHint().height(),
|
|
||||||
device_box.minimumSizeHint().height(),
|
|
||||||
device_box.sizeHint().height(),
|
|
||||||
)
|
|
||||||
device_box.setFixedHeight(self._line_height)
|
|
||||||
device_box.setMinimumWidth(self.dimensions[1])
|
|
||||||
device_box.setMaximumWidth(16777215)
|
|
||||||
self.setFixedHeight(self._line_height)
|
|
||||||
self.setMinimumWidth(self.dimensions[1])
|
|
||||||
|
|
||||||
self.ui.verticalLayout.setContentsMargins(0, 0, 0, 0)
|
|
||||||
self.ui.verticalLayout.setSpacing(0)
|
|
||||||
self.ui.readback.setMaximumWidth(16777215)
|
|
||||||
self.ui.setpoint.setMaximumWidth(16777215)
|
|
||||||
self.ui.step_size.setMaximumWidth(16777215)
|
|
||||||
|
|
||||||
indicator_hint = indicator.minimumSizeHint()
|
|
||||||
step_hint = self.ui.step_size.sizeHint()
|
|
||||||
self._indicator_thickness = max(indicator_hint.height(), 10)
|
|
||||||
self._vertical_indicator_width = max(indicator.minimumWidth(), 15)
|
|
||||||
self._horizontal_indicator_width = max(90, step_hint.width())
|
|
||||||
base_width = max(device_box.minimumSizeHint().width(), self.dimensions[1])
|
|
||||||
self._indicator_switch_width = (
|
|
||||||
base_width - self._vertical_indicator_width + self._horizontal_indicator_width
|
|
||||||
)
|
|
||||||
|
|
||||||
def resizeEvent(self, event):
|
|
||||||
super().resizeEvent(event)
|
|
||||||
self._update_indicator_orientation()
|
|
||||||
|
|
||||||
def _update_indicator_orientation(self):
|
|
||||||
if not hasattr(self, "ui"):
|
|
||||||
return
|
|
||||||
|
|
||||||
indicator = self.ui.position_indicator
|
|
||||||
available_width = self.ui.device_box.width() or self.width() or self.dimensions[1]
|
|
||||||
should_use_horizontal = available_width >= self._indicator_switch_width
|
|
||||||
if should_use_horizontal == self._indicator_is_horizontal:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._indicator_is_horizontal = should_use_horizontal
|
|
||||||
indicator.vertical = not should_use_horizontal
|
|
||||||
|
|
||||||
if should_use_horizontal:
|
|
||||||
indicator.setMinimumSize(self._horizontal_indicator_width, self._indicator_thickness)
|
|
||||||
indicator.setMaximumHeight(self._indicator_thickness)
|
|
||||||
indicator.setMaximumWidth(16777215)
|
|
||||||
indicator.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Fixed)
|
|
||||||
else:
|
|
||||||
indicator.setMinimumSize(self._vertical_indicator_width, self._indicator_thickness)
|
|
||||||
indicator.setMaximumSize(self._vertical_indicator_width, 16777215)
|
|
||||||
indicator.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred)
|
|
||||||
|
|
||||||
indicator.updateGeometry()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
|||||||
+4
-31
@@ -2,18 +2,12 @@
|
|||||||
<ui version="4.0">
|
<ui version="4.0">
|
||||||
<class>Form</class>
|
<class>Form</class>
|
||||||
<widget class="QWidget" name="Form">
|
<widget class="QWidget" name="Form">
|
||||||
<property name="sizePolicy">
|
|
||||||
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
|
||||||
<horstretch>0</horstretch>
|
|
||||||
<verstretch>0</verstretch>
|
|
||||||
</sizepolicy>
|
|
||||||
</property>
|
|
||||||
<property name="geometry">
|
<property name="geometry">
|
||||||
<rect>
|
<rect>
|
||||||
<x>0</x>
|
<x>0</x>
|
||||||
<y>0</y>
|
<y>0</y>
|
||||||
<width>592</width>
|
<width>612</width>
|
||||||
<height>76</height>
|
<height>91</height>
|
||||||
</rect>
|
</rect>
|
||||||
</property>
|
</property>
|
||||||
<property name="minimumSize">
|
<property name="minimumSize">
|
||||||
@@ -32,29 +26,8 @@
|
|||||||
<string>Form</string>
|
<string>Form</string>
|
||||||
</property>
|
</property>
|
||||||
<layout class="QVBoxLayout" name="verticalLayout">
|
<layout class="QVBoxLayout" name="verticalLayout">
|
||||||
<property name="spacing">
|
|
||||||
<number>0</number>
|
|
||||||
</property>
|
|
||||||
<property name="leftMargin">
|
|
||||||
<number>0</number>
|
|
||||||
</property>
|
|
||||||
<property name="topMargin">
|
|
||||||
<number>0</number>
|
|
||||||
</property>
|
|
||||||
<property name="rightMargin">
|
|
||||||
<number>0</number>
|
|
||||||
</property>
|
|
||||||
<property name="bottomMargin">
|
|
||||||
<number>0</number>
|
|
||||||
</property>
|
|
||||||
<item>
|
<item>
|
||||||
<widget class="QGroupBox" name="device_box">
|
<widget class="QGroupBox" name="device_box">
|
||||||
<property name="sizePolicy">
|
|
||||||
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
|
||||||
<horstretch>0</horstretch>
|
|
||||||
<verstretch>0</verstretch>
|
|
||||||
</sizepolicy>
|
|
||||||
</property>
|
|
||||||
<property name="title">
|
<property name="title">
|
||||||
<string>Device Name</string>
|
<string>Device Name</string>
|
||||||
</property>
|
</property>
|
||||||
@@ -254,12 +227,12 @@
|
|||||||
<customwidgets>
|
<customwidgets>
|
||||||
<customwidget>
|
<customwidget>
|
||||||
<class>PositionIndicator</class>
|
<class>PositionIndicator</class>
|
||||||
<extends></extends>
|
<extends>QWidget</extends>
|
||||||
<header>position_indicator</header>
|
<header>position_indicator</header>
|
||||||
</customwidget>
|
</customwidget>
|
||||||
<customwidget>
|
<customwidget>
|
||||||
<class>SpinnerWidget</class>
|
<class>SpinnerWidget</class>
|
||||||
<extends></extends>
|
<extends>QWidget</extends>
|
||||||
<header>spinner_widget</header>
|
<header>spinner_widget</header>
|
||||||
</customwidget>
|
</customwidget>
|
||||||
</customwidgets>
|
</customwidgets>
|
||||||
|
|||||||
@@ -27,13 +27,30 @@ class PositionerGroupBox(QGroupBox):
|
|||||||
self.layout().setContentsMargins(0, 0, 0, 0)
|
self.layout().setContentsMargins(0, 0, 0, 0)
|
||||||
self.layout().setSpacing(0)
|
self.layout().setSpacing(0)
|
||||||
self.widget = PositionerBox(self, dev_name)
|
self.widget = PositionerBox(self, dev_name)
|
||||||
|
self.widget.compact_view = True
|
||||||
|
self.widget.expand_popup = False
|
||||||
self.layout().addWidget(self.widget)
|
self.layout().addWidget(self.widget)
|
||||||
self.widget.position_update.connect(self._on_position_update)
|
self.widget.position_update.connect(self._on_position_update)
|
||||||
|
self.widget.expand.connect(self._on_expand)
|
||||||
self.setTitle(self.device_name)
|
self.setTitle(self.device_name)
|
||||||
self.widget.force_update_readback()
|
self.widget.force_update_readback()
|
||||||
|
|
||||||
|
def _on_expand(self, expand):
|
||||||
|
if expand:
|
||||||
|
self.setTitle("")
|
||||||
|
self.setFlat(True)
|
||||||
|
else:
|
||||||
|
self.setTitle(self.device_name)
|
||||||
|
self.setFlat(False)
|
||||||
|
|
||||||
def _on_position_update(self, pos: float):
|
def _on_position_update(self, pos: float):
|
||||||
self.position_update.emit(pos)
|
self.position_update.emit(pos)
|
||||||
|
precision = getattr(self.widget.dev[self.widget.device], "precision", 8)
|
||||||
|
try:
|
||||||
|
precision = int(precision)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
precision = int(8)
|
||||||
|
self.widget.label = f"{pos:.{precision}f}"
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
self.widget.close()
|
self.widget.close()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from bec_lib.device import Signal as BECSignal
|
|||||||
from bec_lib.logger import bec_logger
|
from bec_lib.logger import bec_logger
|
||||||
from pydantic import field_validator
|
from pydantic import field_validator
|
||||||
|
|
||||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
from bec_widgets.utils import ConnectionConfig
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
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 FilterIO
|
from bec_widgets.utils.filter_io import FilterIO
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from bec_lib.device import Signal
|
|||||||
from bec_lib.logger import bec_logger
|
from bec_lib.logger import bec_logger
|
||||||
from qtpy.QtCore import Property
|
from qtpy.QtCore import Property
|
||||||
|
|
||||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
from bec_widgets.utils import ConnectionConfig
|
||||||
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.filter_io import FilterIO
|
from bec_widgets.utils.filter_io import FilterIO
|
||||||
@@ -266,7 +266,6 @@ class DeviceSignalInputBase(BECWidget):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
device(str): Device to validate.
|
device(str): Device to validate.
|
||||||
raise_on_false(bool): Raise ValueError if device is not found.
|
|
||||||
"""
|
"""
|
||||||
if device in self.dev:
|
if device in self.dev:
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
from typing import Any, Callable, Generator, Iterable, TypeVar
|
from typing import Any, Callable, Generator, Iterable, TypeVar
|
||||||
|
|
||||||
from bec_lib.utils.json_extended import ExtendedEncoder
|
from bec_lib.utils.json import ExtendedEncoder
|
||||||
from qtpy.QtCore import QByteArray, QMimeData, QObject, Signal # type: ignore
|
from qtpy.QtCore import QByteArray, QMimeData, QObject, Signal # type: ignore
|
||||||
from qtpy.QtWidgets import QListWidgetItem
|
from qtpy.QtWidgets import QListWidgetItem
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -51,7 +51,8 @@ class _DeviceEntryWidget(QFrame):
|
|||||||
self.setToolTip(self._rich_text())
|
self.setToolTip(self._rich_text())
|
||||||
|
|
||||||
def _rich_text(self):
|
def _rich_text(self):
|
||||||
return dedent(f"""
|
return dedent(
|
||||||
|
f"""
|
||||||
<b><u><h2> {self._device_spec.name}: </h2></u></b>
|
<b><u><h2> {self._device_spec.name}: </h2></u></b>
|
||||||
<table>
|
<table>
|
||||||
<tr><td> description: </td><td><i> {self._device_spec.description} </i></td></tr>
|
<tr><td> description: </td><td><i> {self._device_spec.description} </i></td></tr>
|
||||||
@@ -59,7 +60,8 @@ class _DeviceEntryWidget(QFrame):
|
|||||||
<tr><td> enabled: </td><td><i> {self._device_spec.enabled} </i></td></tr>
|
<tr><td> enabled: </td><td><i> {self._device_spec.enabled} </i></td></tr>
|
||||||
<tr><td> read only: </td><td><i> {self._device_spec.readOnly} </i></td></tr>
|
<tr><td> read only: </td><td><i> {self._device_spec.readOnly} </i></td></tr>
|
||||||
</table>
|
</table>
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
def setup_title_layout(self, device_spec: HashableDevice):
|
def setup_title_layout(self, device_spec: HashableDevice):
|
||||||
self._title_layout = QHBoxLayout()
|
self._title_layout = QHBoxLayout()
|
||||||
|
|||||||
+2
-2
@@ -13,7 +13,7 @@ if TYPE_CHECKING:
|
|||||||
from .available_device_group import AvailableDeviceGroup
|
from .available_device_group import AvailableDeviceGroup
|
||||||
|
|
||||||
|
|
||||||
class _DeviceListWidget(QListWidget):
|
class _DeviceListWiget(QListWidget):
|
||||||
|
|
||||||
def _item_iter(self):
|
def _item_iter(self):
|
||||||
return (self.item(i) for i in range(self.count()))
|
return (self.item(i) for i in range(self.count()))
|
||||||
@@ -44,7 +44,7 @@ class Ui_AvailableDeviceGroup(object):
|
|||||||
self.n_included.setObjectName("n_included")
|
self.n_included.setObjectName("n_included")
|
||||||
title_layout.addWidget(self.n_included)
|
title_layout.addWidget(self.n_included)
|
||||||
|
|
||||||
self.device_list = _DeviceListWidget(AvailableDeviceGroup)
|
self.device_list = _DeviceListWiget(AvailableDeviceGroup)
|
||||||
self.device_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
|
self.device_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
|
||||||
self.device_list.setObjectName("device_list")
|
self.device_list.setObjectName("device_list")
|
||||||
self.device_list.setFrameStyle(0)
|
self.device_list.setFrameStyle(0)
|
||||||
|
|||||||
+2
-2
@@ -34,13 +34,13 @@ class HashModel(str, Enum):
|
|||||||
class DeviceResourceBackend(Protocol):
|
class DeviceResourceBackend(Protocol):
|
||||||
@property
|
@property
|
||||||
def tag_groups(self) -> dict[str, set[HashableDevice]]:
|
def tag_groups(self) -> dict[str, set[HashableDevice]]:
|
||||||
"""A dictionary of all available devices separated by tag groups. The same device may
|
"""A dictionary of all availble devices separated by tag groups. The same device may
|
||||||
appear more than once (in different groups)."""
|
appear more than once (in different groups)."""
|
||||||
...
|
...
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def all_devices(self) -> set[HashableDevice]:
|
def all_devices(self) -> set[HashableDevice]:
|
||||||
"""A set of all available devices. The same device may not appear more than once."""
|
"""A set of all availble devices. The same device may not appear more than once."""
|
||||||
...
|
...
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ in DeviceTableRow entries.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import traceback
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from typing import TYPE_CHECKING, Any, Callable, Iterable, Tuple
|
from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal, Tuple
|
||||||
|
|
||||||
from bec_lib.atlas_models import Device as DeviceModel
|
from bec_lib.atlas_models import Device as DeviceModel
|
||||||
from bec_lib.callback_handler import EventType
|
from bec_lib.callback_handler import EventType
|
||||||
@@ -18,7 +19,6 @@ from thefuzz import fuzz
|
|||||||
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 SafeSlot
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
from bec_widgets.utils.fuzzy_search import is_match
|
|
||||||
from bec_widgets.widgets.control.device_manager.components.device_table.device_table_row import (
|
from bec_widgets.widgets.control.device_manager.components.device_table.device_table_row import (
|
||||||
DeviceTableRow,
|
DeviceTableRow,
|
||||||
)
|
)
|
||||||
@@ -37,6 +37,34 @@ _DeviceCfgIter = Iterable[dict[str, Any]]
|
|||||||
# DeviceValidationResult: device_config, config_status, connection_status, error_message
|
# DeviceValidationResult: device_config, config_status, connection_status, error_message
|
||||||
_ValidationResultIter = Iterable[Tuple[dict[str, Any], ConfigStatus, ConnectionStatus, str]]
|
_ValidationResultIter = Iterable[Tuple[dict[str, Any], ConfigStatus, ConnectionStatus, str]]
|
||||||
|
|
||||||
|
FUZZY_SEARCH_THRESHOLD = 80
|
||||||
|
|
||||||
|
|
||||||
|
def is_match(
|
||||||
|
text: str, row_data: dict[str, Any], relevant_keys: list[str], enable_fuzzy: bool
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the text matches any of the relevant keys in the row data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text (str): The text to search for.
|
||||||
|
row_data (dict[str, Any]): The row data to search in.
|
||||||
|
relevant_keys (list[str]): The keys to consider for searching.
|
||||||
|
enable_fuzzy (bool): Whether to use fuzzy matching.
|
||||||
|
Returns:
|
||||||
|
bool: True if a match is found, False otherwise.
|
||||||
|
"""
|
||||||
|
for key in relevant_keys:
|
||||||
|
data = str(row_data.get(key, "") or "")
|
||||||
|
if enable_fuzzy:
|
||||||
|
match_ratio = fuzz.partial_ratio(text.lower(), data.lower())
|
||||||
|
if match_ratio >= FUZZY_SEARCH_THRESHOLD:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
if text.lower() in data.lower():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class TableSortOnHold:
|
class TableSortOnHold:
|
||||||
"""Context manager for putting table sorting on hold. Works with nested calls."""
|
"""Context manager for putting table sorting on hold. Works with nested calls."""
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from qtpy.QtGui import QColor
|
|||||||
from qtpy.QtWidgets import (
|
from qtpy.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QGroupBox,
|
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
@@ -19,7 +18,7 @@ from qtpy.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
from bec_widgets.utils 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
|
||||||
@@ -172,13 +171,7 @@ class ScanControl(BECWidget, QWidget):
|
|||||||
self.layout.addStretch()
|
self.layout.addStretch()
|
||||||
|
|
||||||
def _add_metadata_form(self):
|
def _add_metadata_form(self):
|
||||||
# Wrap metadata form in a group box
|
self.layout.addWidget(self._metadata_form)
|
||||||
self._metadata_group = QGroupBox("Scan Metadata", self)
|
|
||||||
self._metadata_group.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
|
||||||
metadata_layout = QVBoxLayout(self._metadata_group)
|
|
||||||
metadata_layout.addWidget(self._metadata_form)
|
|
||||||
|
|
||||||
self.layout.addWidget(self._metadata_group)
|
|
||||||
self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText())
|
self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText())
|
||||||
self.scan_selected.connect(self._metadata_form.update_with_new_scan)
|
self.scan_selected.connect(self._metadata_form.update_with_new_scan)
|
||||||
self._metadata_form.form_data_updated.connect(self.update_scan_metadata)
|
self._metadata_form.form_data_updated.connect(self.update_scan_metadata)
|
||||||
|
|||||||
@@ -347,14 +347,14 @@ class ScanGroupBox(QGroupBox):
|
|||||||
|
|
||||||
def get_parameters(self, device_object: bool = True):
|
def get_parameters(self, device_object: bool = True):
|
||||||
"""
|
"""
|
||||||
Returns the parameters from the widgets in the scan control layout formatted to run scan from BEC.
|
Returns the parameters from the widgets in the scan control layout formated to run scan from BEC.
|
||||||
"""
|
"""
|
||||||
if self.box_type == "args":
|
if self.box_type == "args":
|
||||||
return self._get_arg_parameters(device_object=device_object)
|
return self._get_arg_parameterts(device_object=device_object)
|
||||||
elif self.box_type == "kwargs":
|
elif self.box_type == "kwargs":
|
||||||
return self._get_kwarg_parameters(device_object=device_object)
|
return self._get_kwarg_parameters(device_object=device_object)
|
||||||
|
|
||||||
def _get_arg_parameters(self, device_object: bool = True):
|
def _get_arg_parameterts(self, device_object: bool = True):
|
||||||
args = []
|
args = []
|
||||||
for i in range(1, self.layout.rowCount()):
|
for i in range(1, self.layout.rowCount()):
|
||||||
for j in range(self.layout.columnCount()):
|
for j in range(self.layout.columnCount()):
|
||||||
|
|||||||
@@ -2,19 +2,22 @@
|
|||||||
|
|
||||||
from bec_lib.logger import bec_logger
|
from bec_lib.logger import bec_logger
|
||||||
from qtpy.QtCore import Property, Signal, Slot
|
from qtpy.QtCore import Property, Signal, Slot
|
||||||
from qtpy.QtWidgets import QComboBox
|
from qtpy.QtWidgets import QComboBox, QVBoxLayout, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
|
|
||||||
|
|
||||||
class DapComboBox(BECWidget, QComboBox):
|
class DapComboBox(BECWidget, QWidget):
|
||||||
"""
|
"""
|
||||||
Editable combobox listing the available DAP models.
|
The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC.
|
||||||
|
|
||||||
The widget behaves as a plain QComboBox and keeps ``fit_model_combobox`` as an alias to itself
|
Args:
|
||||||
for backwards compatibility with older call sites.
|
parent: Parent widget.
|
||||||
|
client: BEC client object.
|
||||||
|
gui_id: GUI ID.
|
||||||
|
default: Default device name.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ICON_NAME = "data_exploration"
|
ICON_NAME = "data_exploration"
|
||||||
@@ -42,20 +45,19 @@ class DapComboBox(BECWidget, QComboBox):
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
super().__init__(parent=parent, client=client, gui_id=gui_id, **kwargs)
|
super().__init__(parent=parent, client=client, gui_id=gui_id, **kwargs)
|
||||||
self.fit_model_combobox = self # Just for backwards compatibility with older call sites, the widget itself is the combobox
|
self.layout = QVBoxLayout(self)
|
||||||
self._available_models: list[str] = []
|
self.fit_model_combobox = QComboBox(self)
|
||||||
|
self.layout.addWidget(self.fit_model_combobox)
|
||||||
|
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self._available_models = None
|
||||||
self._x_axis = None
|
self._x_axis = None
|
||||||
self._y_axis = None
|
self._y_axis = None
|
||||||
self._is_valid_input = False
|
|
||||||
|
|
||||||
self.setEditable(True)
|
|
||||||
|
|
||||||
self.populate_fit_model_combobox()
|
self.populate_fit_model_combobox()
|
||||||
self.currentTextChanged.connect(self._on_text_changed)
|
self.fit_model_combobox.currentTextChanged.connect(self._update_current_fit)
|
||||||
|
# Set default fit model
|
||||||
self.select_default_fit(default_fit)
|
self.select_default_fit(default_fit)
|
||||||
self.check_validity(self.currentText())
|
|
||||||
|
|
||||||
def select_default_fit(self, default_fit: str | None = "GaussianModel"):
|
def select_default_fit(self, default_fit: str | None):
|
||||||
"""Set the default fit model.
|
"""Set the default fit model.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -63,8 +65,8 @@ class DapComboBox(BECWidget, QComboBox):
|
|||||||
"""
|
"""
|
||||||
if self._validate_dap_model(default_fit):
|
if self._validate_dap_model(default_fit):
|
||||||
self.select_fit_model(default_fit)
|
self.select_fit_model(default_fit)
|
||||||
elif self.available_models:
|
else:
|
||||||
self.select_fit_model(self.available_models[0])
|
self.select_fit_model("GaussianModel")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available_models(self):
|
def available_models(self):
|
||||||
@@ -112,40 +114,12 @@ class DapComboBox(BECWidget, QComboBox):
|
|||||||
self._y_axis = y_axis
|
self._y_axis = y_axis
|
||||||
self.y_axis_updated.emit(y_axis)
|
self.y_axis_updated.emit(y_axis)
|
||||||
|
|
||||||
@Slot(str)
|
def _update_current_fit(self, fit_name: str):
|
||||||
def _on_text_changed(self, fit_name: str):
|
"""Update the current fit."""
|
||||||
"""
|
|
||||||
Validate and emit updates for the current text.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
fit_name(str): The current text in the combobox, representing the selected fit model.
|
|
||||||
"""
|
|
||||||
self.check_validity(fit_name)
|
|
||||||
if not self._is_valid_input:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.fit_model_updated.emit(fit_name)
|
self.fit_model_updated.emit(fit_name)
|
||||||
if self.x_axis is not None and self.y_axis is not None:
|
if self.x_axis is not None and self.y_axis is not None:
|
||||||
self.new_dap_config.emit(self._x_axis, self._y_axis, fit_name)
|
self.new_dap_config.emit(self._x_axis, self._y_axis, fit_name)
|
||||||
|
|
||||||
@Slot(str)
|
|
||||||
def check_validity(self, fit_name: str):
|
|
||||||
"""
|
|
||||||
Highlight invalid manual entries similarly to DeviceComboBox.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
fit_name(str): The current text in the combobox, representing the selected fit model.
|
|
||||||
"""
|
|
||||||
if self._validate_dap_model(fit_name):
|
|
||||||
self._is_valid_input = True
|
|
||||||
self.setStyleSheet("border: 1px solid transparent;")
|
|
||||||
else:
|
|
||||||
self._is_valid_input = False
|
|
||||||
if self.isEnabled():
|
|
||||||
self.setStyleSheet("border: 1px solid red;")
|
|
||||||
else:
|
|
||||||
self.setStyleSheet("border: 1px solid transparent;")
|
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
def select_x_axis(self, x_axis: str):
|
def select_x_axis(self, x_axis: str):
|
||||||
"""Slot to update the x axis.
|
"""Slot to update the x axis.
|
||||||
@@ -154,7 +128,7 @@ class DapComboBox(BECWidget, QComboBox):
|
|||||||
x_axis(str): X axis.
|
x_axis(str): X axis.
|
||||||
"""
|
"""
|
||||||
self.x_axis = x_axis
|
self.x_axis = x_axis
|
||||||
self._on_text_changed(self.currentText())
|
self._update_current_fit(self.fit_model_combobox.currentText())
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
def select_y_axis(self, y_axis: str):
|
def select_y_axis(self, y_axis: str):
|
||||||
@@ -164,26 +138,25 @@ class DapComboBox(BECWidget, QComboBox):
|
|||||||
y_axis(str): Y axis.
|
y_axis(str): Y axis.
|
||||||
"""
|
"""
|
||||||
self.y_axis = y_axis
|
self.y_axis = y_axis
|
||||||
self._on_text_changed(self.currentText())
|
self._update_current_fit(self.fit_model_combobox.currentText())
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
def select_fit_model(self, fit_name: str | None):
|
def select_fit_model(self, fit_name: str | None):
|
||||||
"""Slot to update the fit model.
|
"""Slot to update the fit model.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
fit_name(str): Fit model name.
|
default_device(str): Default device name.
|
||||||
"""
|
"""
|
||||||
if not self._validate_dap_model(fit_name):
|
if not self._validate_dap_model(fit_name):
|
||||||
raise ValueError(f"Fit {fit_name} is not valid.")
|
raise ValueError(f"Fit {fit_name} is not valid.")
|
||||||
self.setCurrentText(fit_name)
|
self.fit_model_combobox.setCurrentText(fit_name)
|
||||||
|
|
||||||
def populate_fit_model_combobox(self):
|
def populate_fit_model_combobox(self):
|
||||||
"""Populate the fit_model_combobox with the devices."""
|
"""Populate the fit_model_combobox with the devices."""
|
||||||
# pylint: disable=protected-access
|
# pylint: disable=protected-access
|
||||||
available_plugins = getattr(getattr(self.client, "dap", None), "_available_dap_plugins", {})
|
self.available_models = [model for model in self.client.dap._available_dap_plugins.keys()]
|
||||||
self.available_models = [model for model in available_plugins.keys()]
|
self.fit_model_combobox.clear()
|
||||||
self.clear()
|
self.fit_model_combobox.addItems(self.available_models)
|
||||||
self.addItems(self.available_models)
|
|
||||||
|
|
||||||
def _validate_dap_model(self, model: str | None) -> bool:
|
def _validate_dap_model(self, model: str | None) -> bool:
|
||||||
"""Validate the DAP model.
|
"""Validate the DAP model.
|
||||||
@@ -193,23 +166,23 @@ class DapComboBox(BECWidget, QComboBox):
|
|||||||
"""
|
"""
|
||||||
if model is None:
|
if model is None:
|
||||||
return False
|
return False
|
||||||
return model in self.available_models
|
if model not in self.available_models:
|
||||||
|
return False
|
||||||
@property
|
return True
|
||||||
def is_valid_input(self) -> bool:
|
|
||||||
"""Whether the current text matches an available DAP model."""
|
|
||||||
return self._is_valid_input
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
import sys
|
# pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
from qtpy.QtWidgets import QApplication
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
from bec_widgets.utils.colors import apply_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication([])
|
||||||
apply_theme("dark")
|
apply_theme("dark")
|
||||||
dialog = DapComboBox()
|
widget = QWidget()
|
||||||
dialog.show()
|
widget.setFixedSize(200, 200)
|
||||||
sys.exit(app.exec_())
|
layout = QVBoxLayout()
|
||||||
|
widget.setLayout(layout)
|
||||||
|
layout.addWidget(DapComboBox())
|
||||||
|
widget.show()
|
||||||
|
app.exec_()
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
import shiboken6
|
|
||||||
from bec_lib.logger import bec_logger
|
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, 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
|
||||||
|
|
||||||
@@ -35,7 +34,7 @@ class LMFitDialog(BECWidget, QWidget):
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initializes the LMFitDialog widget.
|
Initialises the LMFitDialog widget.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
parent (QWidget): The parent widget.
|
parent (QWidget): The parent widget.
|
||||||
@@ -69,27 +68,6 @@ class LMFitDialog(BECWidget, QWidget):
|
|||||||
self._hide_curve_selection = False
|
self._hide_curve_selection = False
|
||||||
self._hide_summary = False
|
self._hide_summary = False
|
||||||
self._hide_parameters = False
|
self._hide_parameters = False
|
||||||
self._configure_embedded_size_policy()
|
|
||||||
|
|
||||||
def _configure_embedded_size_policy(self):
|
|
||||||
"""Allow the compact dialog to shrink more gracefully in embedded layouts."""
|
|
||||||
if self._ui_file != "lmfit_dialog_compact.ui":
|
|
||||||
return
|
|
||||||
|
|
||||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
|
||||||
self.ui.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
|
||||||
|
|
||||||
for group in (
|
|
||||||
self.ui.group_curve_selection,
|
|
||||||
self.ui.group_summary,
|
|
||||||
self.ui.group_parameters,
|
|
||||||
):
|
|
||||||
group.setMinimumHeight(0)
|
|
||||||
group.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
|
||||||
|
|
||||||
for view in (self.ui.curve_list, self.ui.summary_tree, self.ui.param_tree):
|
|
||||||
view.setMinimumHeight(0)
|
|
||||||
view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Ignored)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def enable_actions(self) -> bool:
|
def enable_actions(self) -> bool:
|
||||||
@@ -99,14 +77,8 @@ class LMFitDialog(BECWidget, QWidget):
|
|||||||
@enable_actions.setter
|
@enable_actions.setter
|
||||||
def enable_actions(self, enable: bool):
|
def enable_actions(self, enable: bool):
|
||||||
self._enable_actions = enable
|
self._enable_actions = enable
|
||||||
valid_buttons = {}
|
for button in self.action_buttons.values():
|
||||||
for name, button in self.action_buttons.items():
|
|
||||||
# just to be sure we have a valid c++ object
|
|
||||||
if button is None or not shiboken6.isValid(button):
|
|
||||||
continue
|
|
||||||
button.setEnabled(enable)
|
button.setEnabled(enable)
|
||||||
valid_buttons[name] = button
|
|
||||||
self.action_buttons = valid_buttons
|
|
||||||
|
|
||||||
@SafeProperty(list)
|
@SafeProperty(list)
|
||||||
def active_action_list(self) -> list[str]:
|
def active_action_list(self) -> list[str]:
|
||||||
@@ -117,6 +89,16 @@ class LMFitDialog(BECWidget, QWidget):
|
|||||||
def active_action_list(self, actions: list[str]):
|
def active_action_list(self, actions: list[str]):
|
||||||
self._active_actions = actions
|
self._active_actions = actions
|
||||||
|
|
||||||
|
# This SafeSlot needed?
|
||||||
|
@SafeSlot(bool)
|
||||||
|
def set_actions_enabled(self, enable: bool) -> bool:
|
||||||
|
"""SafeSlot to enable the move to buttons.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
enable (bool): Whether to enable the action buttons.
|
||||||
|
"""
|
||||||
|
self.enable_actions = enable
|
||||||
|
|
||||||
@SafeProperty(bool)
|
@SafeProperty(bool)
|
||||||
def always_show_latest(self):
|
def always_show_latest(self):
|
||||||
"""SafeProperty to indicate if always the latest DAP update is displayed."""
|
"""SafeProperty to indicate if always the latest DAP update is displayed."""
|
||||||
@@ -172,21 +154,19 @@ class LMFitDialog(BECWidget, QWidget):
|
|||||||
self.ui.group_parameters.setVisible(not show)
|
self.ui.group_parameters.setVisible(not show)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def fit_curve_id(self) -> str | None:
|
def fit_curve_id(self) -> str:
|
||||||
"""SafeProperty for the currently displayed fit curve_id."""
|
"""SafeProperty for the currently displayed fit curve_id."""
|
||||||
return self._fit_curve_id
|
return self._fit_curve_id
|
||||||
|
|
||||||
@fit_curve_id.setter
|
@fit_curve_id.setter
|
||||||
def fit_curve_id(self, curve_id: str | None):
|
def fit_curve_id(self, curve_id: str):
|
||||||
"""Setter for the currently displayed fit curve_id.
|
"""Setter for the currently displayed fit curve_id.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
curve_id (str | None): The curve_id of the fit curve to be displayed,
|
fit_curve_id (str): The curve_id of the fit curve to be displayed.
|
||||||
or None to clear the selection.
|
|
||||||
"""
|
"""
|
||||||
self._fit_curve_id = curve_id
|
self._fit_curve_id = curve_id
|
||||||
if curve_id is not None:
|
self.selected_fit.emit(curve_id)
|
||||||
self.selected_fit.emit(curve_id)
|
|
||||||
|
|
||||||
@SafeSlot(str)
|
@SafeSlot(str)
|
||||||
def remove_dap_data(self, curve_id: str):
|
def remove_dap_data(self, curve_id: str):
|
||||||
@@ -196,15 +176,6 @@ class LMFitDialog(BECWidget, QWidget):
|
|||||||
curve_id (str): The curve_id of the DAP data to be removed.
|
curve_id (str): The curve_id of the DAP data to be removed.
|
||||||
"""
|
"""
|
||||||
self.summary_data.pop(curve_id, None)
|
self.summary_data.pop(curve_id, None)
|
||||||
if self.fit_curve_id == curve_id:
|
|
||||||
self.action_buttons = {}
|
|
||||||
self.ui.summary_tree.clear()
|
|
||||||
self.ui.param_tree.clear()
|
|
||||||
remaining = list(self.summary_data.keys())
|
|
||||||
if remaining:
|
|
||||||
self.fit_curve_id = remaining[0]
|
|
||||||
else:
|
|
||||||
self._fit_curve_id = None
|
|
||||||
self.refresh_curve_list()
|
self.refresh_curve_list()
|
||||||
|
|
||||||
@SafeSlot(str)
|
@SafeSlot(str)
|
||||||
@@ -280,7 +251,6 @@ class LMFitDialog(BECWidget, QWidget):
|
|||||||
params (list): List of LMFit parameters for the fit curve.
|
params (list): List of LMFit parameters for the fit curve.
|
||||||
"""
|
"""
|
||||||
self._move_buttons = []
|
self._move_buttons = []
|
||||||
self.action_buttons = {}
|
|
||||||
self.ui.param_tree.clear()
|
self.ui.param_tree.clear()
|
||||||
for param in params:
|
for param in params:
|
||||||
param_name = param[0]
|
param_name = param[0]
|
||||||
@@ -299,16 +269,18 @@ class LMFitDialog(BECWidget, QWidget):
|
|||||||
if param_name in self.active_action_list: # pylint: disable=unsupported-membership-test
|
if param_name in self.active_action_list: # pylint: disable=unsupported-membership-test
|
||||||
# Create a push button to move the motor to a specific position
|
# Create a push button to move the motor to a specific position
|
||||||
widget = QWidget()
|
widget = QWidget()
|
||||||
button = QPushButton("Move")
|
button = QPushButton(f"Move to {param_name}")
|
||||||
button.clicked.connect(self._create_move_action(param_name, param[1]))
|
button.clicked.connect(self._create_move_action(param_name, param[1]))
|
||||||
if self.enable_actions:
|
if self.enable_actions is True:
|
||||||
button.setEnabled(True)
|
button.setEnabled(True)
|
||||||
else:
|
else:
|
||||||
button.setEnabled(False)
|
button.setEnabled(False)
|
||||||
button.setStyleSheet(f"""
|
button.setStyleSheet(
|
||||||
|
f"""
|
||||||
QPushButton:enabled {{ background-color: {self._accent_colors.success.name()};color: white; }}
|
QPushButton:enabled {{ background-color: {self._accent_colors.success.name()};color: white; }}
|
||||||
QPushButton:disabled {{ background-color: grey;color: white; }}
|
QPushButton:disabled {{ background-color: grey;color: white; }}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
self.action_buttons[param_name] = button
|
self.action_buttons[param_name] = button
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
layout.addWidget(self.action_buttons[param_name])
|
layout.addWidget(self.action_buttons[param_name])
|
||||||
|
|||||||
@@ -14,18 +14,6 @@
|
|||||||
<string>Form</string>
|
<string>Form</string>
|
||||||
</property>
|
</property>
|
||||||
<layout class="QGridLayout" name="gridLayout">
|
<layout class="QGridLayout" name="gridLayout">
|
||||||
<property name="leftMargin">
|
|
||||||
<number>0</number>
|
|
||||||
</property>
|
|
||||||
<property name="topMargin">
|
|
||||||
<number>0</number>
|
|
||||||
</property>
|
|
||||||
<property name="rightMargin">
|
|
||||||
<number>0</number>
|
|
||||||
</property>
|
|
||||||
<property name="bottomMargin">
|
|
||||||
<number>0</number>
|
|
||||||
</property>
|
|
||||||
<item row="0" column="0">
|
<item row="0" column="0">
|
||||||
<widget class="QSplitter" name="splitter_2">
|
<widget class="QSplitter" name="splitter_2">
|
||||||
<property name="sizePolicy">
|
<property name="sizePolicy">
|
||||||
@@ -34,6 +22,15 @@
|
|||||||
<verstretch>0</verstretch>
|
<verstretch>0</verstretch>
|
||||||
</sizepolicy>
|
</sizepolicy>
|
||||||
</property>
|
</property>
|
||||||
|
<property name="frameShape">
|
||||||
|
<enum>QFrame::Shape::VLine</enum>
|
||||||
|
</property>
|
||||||
|
<property name="frameShadow">
|
||||||
|
<enum>QFrame::Shadow::Plain</enum>
|
||||||
|
</property>
|
||||||
|
<property name="lineWidth">
|
||||||
|
<number>1</number>
|
||||||
|
</property>
|
||||||
<property name="orientation">
|
<property name="orientation">
|
||||||
<enum>Qt::Orientation::Horizontal</enum>
|
<enum>Qt::Orientation::Horizontal</enum>
|
||||||
</property>
|
</property>
|
||||||
@@ -44,12 +41,6 @@
|
|||||||
<bool>true</bool>
|
<bool>true</bool>
|
||||||
</property>
|
</property>
|
||||||
<widget class="QGroupBox" name="group_curve_selection">
|
<widget class="QGroupBox" name="group_curve_selection">
|
||||||
<property name="minimumSize">
|
|
||||||
<size>
|
|
||||||
<width>120</width>
|
|
||||||
<height>0</height>
|
|
||||||
</size>
|
|
||||||
</property>
|
|
||||||
<property name="title">
|
<property name="title">
|
||||||
<string>Select Curve</string>
|
<string>Select Curve</string>
|
||||||
</property>
|
</property>
|
||||||
@@ -67,36 +58,18 @@
|
|||||||
</sizepolicy>
|
</sizepolicy>
|
||||||
</property>
|
</property>
|
||||||
<property name="orientation">
|
<property name="orientation">
|
||||||
<enum>Qt::Orientation::Horizontal</enum>
|
<enum>Qt::Orientation::Vertical</enum>
|
||||||
</property>
|
</property>
|
||||||
<widget class="QGroupBox" name="group_summary">
|
<widget class="QGroupBox" name="group_summary">
|
||||||
<property name="minimumSize">
|
|
||||||
<size>
|
|
||||||
<width>180</width>
|
|
||||||
<height>0</height>
|
|
||||||
</size>
|
|
||||||
</property>
|
|
||||||
<property name="title">
|
<property name="title">
|
||||||
<string>Fit Summary</string>
|
<string>Fit Summary</string>
|
||||||
</property>
|
</property>
|
||||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||||
<item>
|
<item>
|
||||||
<widget class="QTreeWidget" name="summary_tree">
|
<widget class="QTreeWidget" name="summary_tree">
|
||||||
<property name="alternatingRowColors">
|
|
||||||
<bool>true</bool>
|
|
||||||
</property>
|
|
||||||
<property name="indentation">
|
|
||||||
<number>0</number>
|
|
||||||
</property>
|
|
||||||
<property name="rootIsDecorated">
|
|
||||||
<bool>false</bool>
|
|
||||||
</property>
|
|
||||||
<property name="uniformRowHeights">
|
<property name="uniformRowHeights">
|
||||||
<bool>false</bool>
|
<bool>false</bool>
|
||||||
</property>
|
</property>
|
||||||
<attribute name="headerDefaultSectionSize">
|
|
||||||
<number>90</number>
|
|
||||||
</attribute>
|
|
||||||
<column>
|
<column>
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Property</string>
|
<string>Property</string>
|
||||||
@@ -112,33 +85,12 @@
|
|||||||
</layout>
|
</layout>
|
||||||
</widget>
|
</widget>
|
||||||
<widget class="QGroupBox" name="group_parameters">
|
<widget class="QGroupBox" name="group_parameters">
|
||||||
<property name="minimumSize">
|
|
||||||
<size>
|
|
||||||
<width>240</width>
|
|
||||||
<height>0</height>
|
|
||||||
</size>
|
|
||||||
</property>
|
|
||||||
<property name="title">
|
<property name="title">
|
||||||
<string>Parameter Details</string>
|
<string>Parameter Details</string>
|
||||||
</property>
|
</property>
|
||||||
<layout class="QVBoxLayout" name="verticalLayout_3">
|
<layout class="QVBoxLayout" name="verticalLayout_3">
|
||||||
<item>
|
<item>
|
||||||
<widget class="QTreeWidget" name="param_tree">
|
<widget class="QTreeWidget" name="param_tree">
|
||||||
<property name="alternatingRowColors">
|
|
||||||
<bool>true</bool>
|
|
||||||
</property>
|
|
||||||
<property name="indentation">
|
|
||||||
<number>0</number>
|
|
||||||
</property>
|
|
||||||
<property name="rootIsDecorated">
|
|
||||||
<bool>false</bool>
|
|
||||||
</property>
|
|
||||||
<property name="columnCount">
|
|
||||||
<number>4</number>
|
|
||||||
</property>
|
|
||||||
<attribute name="headerDefaultSectionSize">
|
|
||||||
<number>80</number>
|
|
||||||
</attribute>
|
|
||||||
<column>
|
<column>
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Parameter</string>
|
<string>Parameter</string>
|
||||||
@@ -154,11 +106,6 @@
|
|||||||
<string>Std</string>
|
<string>Std</string>
|
||||||
</property>
|
</property>
|
||||||
</column>
|
</column>
|
||||||
<column>
|
|
||||||
<property name="text">
|
|
||||||
<string>Action</string>
|
|
||||||
</property>
|
|
||||||
</column>
|
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
</layout>
|
</layout>
|
||||||
|
|||||||
@@ -95,12 +95,6 @@
|
|||||||
<height>0</height>
|
<height>0</height>
|
||||||
</size>
|
</size>
|
||||||
</property>
|
</property>
|
||||||
<property name="indentation">
|
|
||||||
<number>0</number>
|
|
||||||
</property>
|
|
||||||
<property name="rootIsDecorated">
|
|
||||||
<bool>false</bool>
|
|
||||||
</property>
|
|
||||||
<property name="uniformRowHeights">
|
<property name="uniformRowHeights">
|
||||||
<bool>false</bool>
|
<bool>false</bool>
|
||||||
</property>
|
</property>
|
||||||
@@ -153,12 +147,6 @@
|
|||||||
<width>0</width>
|
<width>0</width>
|
||||||
<height>0</height>
|
<height>0</height>
|
||||||
</size>
|
</size>
|
||||||
</property>
|
|
||||||
<property name="indentation">
|
|
||||||
<number>0</number>
|
|
||||||
</property>
|
|
||||||
<property name="rootIsDecorated">
|
|
||||||
<bool>false</bool>
|
|
||||||
</property>
|
</property>
|
||||||
<property name="columnCount">
|
<property name="columnCount">
|
||||||
<number>4</number>
|
<number>4</number>
|
||||||
|
|||||||
@@ -1,605 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import enum
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from uuid import uuid4
|
|
||||||
from weakref import WeakValueDictionary
|
|
||||||
|
|
||||||
import shiboken6
|
|
||||||
from bec_lib.logger import bec_logger
|
|
||||||
from qtpy.QtCore import Qt, Signal
|
|
||||||
from qtpy.QtGui import QMouseEvent
|
|
||||||
from qtpy.QtWidgets import (
|
|
||||||
QApplication,
|
|
||||||
QHBoxLayout,
|
|
||||||
QLabel,
|
|
||||||
QStackedLayout,
|
|
||||||
QTabWidget,
|
|
||||||
QVBoxLayout,
|
|
||||||
QWidget,
|
|
||||||
)
|
|
||||||
|
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
|
||||||
from bec_widgets.widgets.utility.bec_term.protocol import BecTerminal
|
|
||||||
from bec_widgets.widgets.utility.bec_term.util import get_current_bec_term_class
|
|
||||||
|
|
||||||
logger = bec_logger.logger
|
|
||||||
|
|
||||||
_BecTermClass = get_current_bec_term_class()
|
|
||||||
|
|
||||||
# Note on definitions:
|
|
||||||
# Terminal: an instance of a terminal widget with a system shell
|
|
||||||
# Console: one of possibly several widgets which may share ownership of one single terminal
|
|
||||||
# Shell: a Console set to start the BEC IPython client in its terminal
|
|
||||||
|
|
||||||
|
|
||||||
class ConsoleMode(str, enum.Enum):
|
|
||||||
ACTIVE = "active"
|
|
||||||
INACTIVE = "inactive"
|
|
||||||
HIDDEN = "hidden"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _TerminalOwnerInfo:
|
|
||||||
"""Should be managed only by the BecConsoleRegistry. Consoles should ask the registry for
|
|
||||||
necessary ownership info."""
|
|
||||||
|
|
||||||
owner_console_id: str | None = None
|
|
||||||
registered_console_ids: set[str] = field(default_factory=set)
|
|
||||||
instance: BecTerminal | None = None
|
|
||||||
terminal_id: str = ""
|
|
||||||
initialized: bool = False
|
|
||||||
persist_session: bool = False
|
|
||||||
fallback_holder: QWidget | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class BecConsoleRegistry:
|
|
||||||
"""
|
|
||||||
A registry for the BecConsole class to manage its instances.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""
|
|
||||||
Initialize the registry.
|
|
||||||
"""
|
|
||||||
self._consoles: WeakValueDictionary[str, BecConsole] = WeakValueDictionary()
|
|
||||||
self._terminal_registry: dict[str, _TerminalOwnerInfo] = {}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _is_valid_qobject(obj: object | None) -> bool:
|
|
||||||
return obj is not None and shiboken6.isValid(obj)
|
|
||||||
|
|
||||||
def _connect_app_cleanup(self) -> None:
|
|
||||||
app = QApplication.instance()
|
|
||||||
if app is None:
|
|
||||||
return
|
|
||||||
app.aboutToQuit.connect(self.clear, Qt.ConnectionType.UniqueConnection)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _new_terminal_info(console: BecConsole) -> _TerminalOwnerInfo:
|
|
||||||
term = _BecTermClass()
|
|
||||||
return _TerminalOwnerInfo(
|
|
||||||
registered_console_ids={console.console_id},
|
|
||||||
owner_console_id=console.console_id,
|
|
||||||
instance=term,
|
|
||||||
terminal_id=console.terminal_id,
|
|
||||||
persist_session=console.persist_terminal_session,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _replace_terminal(info: _TerminalOwnerInfo, console: BecConsole) -> None:
|
|
||||||
info.instance = _BecTermClass()
|
|
||||||
info.initialized = False
|
|
||||||
info.owner_console_id = console.console_id
|
|
||||||
info.registered_console_ids.add(console.console_id)
|
|
||||||
info.persist_session = info.persist_session or console.persist_terminal_session
|
|
||||||
|
|
||||||
def _delete_terminal_info(self, info: _TerminalOwnerInfo) -> None:
|
|
||||||
if self._is_valid_qobject(info.instance):
|
|
||||||
info.instance.deleteLater() # type: ignore[union-attr]
|
|
||||||
info.instance = None
|
|
||||||
if self._is_valid_qobject(info.fallback_holder):
|
|
||||||
info.fallback_holder.deleteLater()
|
|
||||||
info.fallback_holder = None
|
|
||||||
|
|
||||||
def _parking_parent(
|
|
||||||
self,
|
|
||||||
info: _TerminalOwnerInfo,
|
|
||||||
console: BecConsole | None = None,
|
|
||||||
*,
|
|
||||||
avoid_console: bool = False,
|
|
||||||
) -> QWidget | None:
|
|
||||||
for console_id in info.registered_console_ids:
|
|
||||||
candidate = self._consoles.get(console_id)
|
|
||||||
if candidate is None or candidate is console:
|
|
||||||
continue
|
|
||||||
if self._is_valid_qobject(candidate):
|
|
||||||
return candidate._term_holder
|
|
||||||
|
|
||||||
if console is None or not self._is_valid_qobject(console):
|
|
||||||
return None
|
|
||||||
|
|
||||||
window = console.window()
|
|
||||||
if window is not None and window is not console and self._is_valid_qobject(window):
|
|
||||||
return window
|
|
||||||
|
|
||||||
if not avoid_console:
|
|
||||||
return console._term_holder
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _fallback_holder(
|
|
||||||
self,
|
|
||||||
info: _TerminalOwnerInfo,
|
|
||||||
console: BecConsole | None = None,
|
|
||||||
*,
|
|
||||||
avoid_console: bool = False,
|
|
||||||
) -> QWidget:
|
|
||||||
if not self._is_valid_qobject(info.fallback_holder):
|
|
||||||
info.fallback_holder = QWidget(
|
|
||||||
parent=self._parking_parent(info, console, avoid_console=avoid_console)
|
|
||||||
)
|
|
||||||
info.fallback_holder.setObjectName(f"_bec_console_terminal_holder_{info.terminal_id}")
|
|
||||||
info.fallback_holder.hide()
|
|
||||||
return info.fallback_holder
|
|
||||||
|
|
||||||
def _park_terminal(
|
|
||||||
self,
|
|
||||||
info: _TerminalOwnerInfo,
|
|
||||||
console: BecConsole | None = None,
|
|
||||||
*,
|
|
||||||
avoid_console: bool = False,
|
|
||||||
) -> None:
|
|
||||||
if not self._is_valid_qobject(info.instance):
|
|
||||||
return
|
|
||||||
|
|
||||||
parent = self._parking_parent(info, console, avoid_console=avoid_console)
|
|
||||||
if parent is None and info.persist_session:
|
|
||||||
parent = self._fallback_holder(info, console, avoid_console=avoid_console)
|
|
||||||
|
|
||||||
info.instance.hide() # type: ignore[union-attr]
|
|
||||||
info.instance.setParent(parent) # type: ignore[union-attr]
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
"""Delete every tracked terminal and holder."""
|
|
||||||
for info in list(self._terminal_registry.values()):
|
|
||||||
self._delete_terminal_info(info)
|
|
||||||
self._terminal_registry.clear()
|
|
||||||
self._consoles.clear()
|
|
||||||
|
|
||||||
def register(self, console: BecConsole):
|
|
||||||
"""
|
|
||||||
Register an instance of BecConsole. If there is already a terminal with the associated
|
|
||||||
terminal_id, this does not automatically grant ownership.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
console (BecConsole): The instance to register.
|
|
||||||
"""
|
|
||||||
self._connect_app_cleanup()
|
|
||||||
self._consoles[console.console_id] = console
|
|
||||||
console_id, terminal_id = console.console_id, console.terminal_id
|
|
||||||
term_info = self._terminal_registry.get(terminal_id)
|
|
||||||
if term_info is None:
|
|
||||||
self._terminal_registry[terminal_id] = self._new_terminal_info(console)
|
|
||||||
return
|
|
||||||
|
|
||||||
term_info.persist_session = term_info.persist_session or console.persist_terminal_session
|
|
||||||
had_registered_consoles = bool(term_info.registered_console_ids)
|
|
||||||
term_info.registered_console_ids.add(console_id)
|
|
||||||
if not self._is_valid_qobject(term_info.instance):
|
|
||||||
self._replace_terminal(term_info, console)
|
|
||||||
return
|
|
||||||
if (
|
|
||||||
term_info.owner_console_id is not None
|
|
||||||
and term_info.owner_console_id not in self._consoles
|
|
||||||
):
|
|
||||||
term_info.owner_console_id = None
|
|
||||||
if term_info.owner_console_id is None and not had_registered_consoles:
|
|
||||||
term_info.owner_console_id = console_id
|
|
||||||
logger.info(f"Registered new console {console_id} for terminal {terminal_id}")
|
|
||||||
|
|
||||||
def unregister(self, console: BecConsole):
|
|
||||||
"""
|
|
||||||
Unregister an instance of BecConsole.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
console (BecConsole): The instance to unregister.
|
|
||||||
"""
|
|
||||||
console_id, terminal_id = console.console_id, console.terminal_id
|
|
||||||
if console_id in self._consoles:
|
|
||||||
del self._consoles[console_id]
|
|
||||||
if (term_info := self._terminal_registry.get(terminal_id)) is None:
|
|
||||||
return
|
|
||||||
detached = console._detach_terminal_widget(term_info.instance)
|
|
||||||
if console_id in term_info.registered_console_ids:
|
|
||||||
term_info.registered_console_ids.remove(console_id)
|
|
||||||
if term_info.owner_console_id == console_id:
|
|
||||||
term_info.owner_console_id = None
|
|
||||||
if not term_info.registered_console_ids:
|
|
||||||
if term_info.persist_session and self._is_valid_qobject(term_info.instance):
|
|
||||||
self._park_terminal(term_info, console, avoid_console=True)
|
|
||||||
logger.info(f"Unregistered console {console_id} for terminal {terminal_id}")
|
|
||||||
return
|
|
||||||
|
|
||||||
self._delete_terminal_info(term_info)
|
|
||||||
del self._terminal_registry[terminal_id]
|
|
||||||
elif detached:
|
|
||||||
self._park_terminal(term_info, console, avoid_console=True)
|
|
||||||
|
|
||||||
logger.info(f"Unregistered console {console_id} for terminal {terminal_id}")
|
|
||||||
|
|
||||||
def is_owner(self, console: BecConsole):
|
|
||||||
"""Returns true if the given console is the owner of its terminal"""
|
|
||||||
if console not in self._consoles.values():
|
|
||||||
return False
|
|
||||||
if (info := self._terminal_registry.get(console.terminal_id)) is None:
|
|
||||||
logger.warning(f"Console {console.console_id} references an unknown terminal!")
|
|
||||||
return False
|
|
||||||
if not self._is_valid_qobject(info.instance):
|
|
||||||
return False
|
|
||||||
return info.owner_console_id == console.console_id
|
|
||||||
|
|
||||||
def take_ownership(self, console: BecConsole) -> BecTerminal | None:
|
|
||||||
"""
|
|
||||||
Transfer ownership of a terminal to the given console.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
console: the console which wishes to take ownership of its associated terminal.
|
|
||||||
Returns:
|
|
||||||
BecTerminal | None: The instance if ownership transfer was successful, None otherwise.
|
|
||||||
"""
|
|
||||||
console_id, terminal_id = console.console_id, console.terminal_id
|
|
||||||
|
|
||||||
if terminal_id not in self._terminal_registry:
|
|
||||||
self.register(console)
|
|
||||||
|
|
||||||
instance_info = self._terminal_registry[terminal_id]
|
|
||||||
if not self._is_valid_qobject(instance_info.instance):
|
|
||||||
self._replace_terminal(instance_info, console)
|
|
||||||
if (old_owner_console_ide := instance_info.owner_console_id) is not None:
|
|
||||||
if (
|
|
||||||
old_owner_console_ide != console_id
|
|
||||||
and (old_owner := self._consoles.get(old_owner_console_ide)) is not None
|
|
||||||
):
|
|
||||||
old_owner.yield_ownership() # call this on the old owner to make sure it is updated
|
|
||||||
instance_info.owner_console_id = console_id
|
|
||||||
instance_info.registered_console_ids.add(console_id)
|
|
||||||
logger.info(f"Transferred ownership of terminal {terminal_id} to {console_id}")
|
|
||||||
return instance_info.instance
|
|
||||||
|
|
||||||
def try_get_term(self, console: BecConsole) -> BecTerminal | None:
|
|
||||||
"""
|
|
||||||
Return the terminal instance if the requesting console is the owner
|
|
||||||
|
|
||||||
Args:
|
|
||||||
console: the requesting console.
|
|
||||||
Returns:
|
|
||||||
BecTerminal | None: The instance if the console is the owner, None otherwise.
|
|
||||||
"""
|
|
||||||
console_id, terminal_id = console.console_id, console.terminal_id
|
|
||||||
logger.debug(f"checking term for {console_id}")
|
|
||||||
if terminal_id not in self._terminal_registry:
|
|
||||||
logger.warning(f"Terminal {terminal_id} not found in registry")
|
|
||||||
return None
|
|
||||||
|
|
||||||
instance_info = self._terminal_registry[terminal_id]
|
|
||||||
if not self._is_valid_qobject(instance_info.instance):
|
|
||||||
if instance_info.owner_console_id == console_id:
|
|
||||||
self._replace_terminal(instance_info, console)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
if instance_info.owner_console_id == console_id:
|
|
||||||
return instance_info.instance
|
|
||||||
|
|
||||||
def yield_ownership(self, console: BecConsole):
|
|
||||||
"""
|
|
||||||
Yield ownership of an instance without destroying it. The instance remains in the
|
|
||||||
registry with no owner, available for another widget to claim.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
console (BecConsole): The console which wishes to yield ownership of its associated terminal.
|
|
||||||
"""
|
|
||||||
console_id, terminal_id = console.console_id, console.terminal_id
|
|
||||||
logger.debug(f"Console {console_id} attempted to yield ownership")
|
|
||||||
if console_id not in self._consoles or terminal_id not in self._terminal_registry:
|
|
||||||
return
|
|
||||||
|
|
||||||
term_info = self._terminal_registry[terminal_id]
|
|
||||||
if term_info.owner_console_id != console_id:
|
|
||||||
logger.debug(f"But it was not the owner, which was {term_info.owner_console_id}!")
|
|
||||||
return
|
|
||||||
term_info.owner_console_id = None
|
|
||||||
console._detach_terminal_widget(term_info.instance)
|
|
||||||
self._park_terminal(term_info, console)
|
|
||||||
|
|
||||||
def should_initialize(self, console: BecConsole) -> bool:
|
|
||||||
"""Return true if the console should send its startup command to the terminal."""
|
|
||||||
info = self._terminal_registry.get(console.terminal_id)
|
|
||||||
if info is None:
|
|
||||||
return False
|
|
||||||
return (
|
|
||||||
info.owner_console_id == console.console_id
|
|
||||||
and not info.initialized
|
|
||||||
and self._is_valid_qobject(info.instance)
|
|
||||||
)
|
|
||||||
|
|
||||||
def mark_initialized(self, console: BecConsole) -> None:
|
|
||||||
info = self._terminal_registry.get(console.terminal_id)
|
|
||||||
if info is not None and info.owner_console_id == console.console_id:
|
|
||||||
info.initialized = True
|
|
||||||
|
|
||||||
def owner_is_visible(self, term_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if the owner of an instance is currently visible.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
term_id (str): The terminal ID to check.
|
|
||||||
Returns:
|
|
||||||
bool: True if the owner is visible, False otherwise.
|
|
||||||
"""
|
|
||||||
instance_info = self._terminal_registry.get(term_id)
|
|
||||||
if (
|
|
||||||
instance_info is None
|
|
||||||
or instance_info.owner_console_id is None
|
|
||||||
or not self._is_valid_qobject(instance_info.instance)
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
|
|
||||||
if (owner := self._consoles.get(instance_info.owner_console_id)) is None:
|
|
||||||
return False
|
|
||||||
return owner.isVisible()
|
|
||||||
|
|
||||||
|
|
||||||
_bec_console_registry = BecConsoleRegistry()
|
|
||||||
|
|
||||||
|
|
||||||
class _Overlay(QWidget):
|
|
||||||
def __init__(self, console: BecConsole):
|
|
||||||
super().__init__(parent=console)
|
|
||||||
self._console = console
|
|
||||||
|
|
||||||
def mousePressEvent(self, event: QMouseEvent) -> None:
|
|
||||||
if event.button() == Qt.MouseButton.LeftButton:
|
|
||||||
self._console.take_terminal_ownership()
|
|
||||||
event.accept()
|
|
||||||
return
|
|
||||||
return super().mousePressEvent(event)
|
|
||||||
|
|
||||||
|
|
||||||
class BecConsole(BECWidget, QWidget):
|
|
||||||
"""A console widget with access to a shared registry of terminals, such that instances can be moved around."""
|
|
||||||
|
|
||||||
_js_callback = Signal(bool)
|
|
||||||
initialized = Signal()
|
|
||||||
|
|
||||||
PLUGIN = True
|
|
||||||
ICON_NAME = "terminal"
|
|
||||||
persist_terminal_session = False
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
parent=None,
|
|
||||||
config=None,
|
|
||||||
client=None,
|
|
||||||
gui_id=None,
|
|
||||||
startup_cmd: str | None = None,
|
|
||||||
terminal_id: str | None = None,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
|
||||||
self._mode = ConsoleMode.INACTIVE
|
|
||||||
self._startup_cmd = startup_cmd
|
|
||||||
self._is_initialized = False
|
|
||||||
self.terminal_id = terminal_id or str(uuid4())
|
|
||||||
self.console_id = self.gui_id
|
|
||||||
self.term: BecTerminal | None = None # Will be set in _set_up_instance
|
|
||||||
|
|
||||||
self._set_up_instance()
|
|
||||||
|
|
||||||
def _set_up_instance(self):
|
|
||||||
"""
|
|
||||||
Set up the web instance and UI elements.
|
|
||||||
"""
|
|
||||||
self._stacked_layout = QStackedLayout()
|
|
||||||
# self._stacked_layout.setStackingMode(QStackedLayout.StackingMode.StackAll)
|
|
||||||
self._term_holder = QWidget()
|
|
||||||
self._term_layout = QVBoxLayout()
|
|
||||||
self._term_layout.setContentsMargins(0, 0, 0, 0)
|
|
||||||
self._term_holder.setLayout(self._term_layout)
|
|
||||||
|
|
||||||
self.setLayout(self._stacked_layout)
|
|
||||||
|
|
||||||
# prepare overlay
|
|
||||||
self._overlay = _Overlay(self)
|
|
||||||
layout = QVBoxLayout(self._overlay)
|
|
||||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
||||||
label = QLabel("Click to activate terminal", self._overlay)
|
|
||||||
layout.addWidget(label)
|
|
||||||
|
|
||||||
self._stacked_layout.addWidget(self._term_holder)
|
|
||||||
self._stacked_layout.addWidget(self._overlay)
|
|
||||||
|
|
||||||
# will create a new terminal instance if there isn't already one for this ID
|
|
||||||
_bec_console_registry.register(self)
|
|
||||||
self._infer_mode()
|
|
||||||
self._ensure_startup_started()
|
|
||||||
|
|
||||||
def _infer_mode(self):
|
|
||||||
self.term = _bec_console_registry.try_get_term(self)
|
|
||||||
if self.term:
|
|
||||||
self._set_mode(ConsoleMode.ACTIVE)
|
|
||||||
elif self.isHidden():
|
|
||||||
self._set_mode(ConsoleMode.HIDDEN)
|
|
||||||
else:
|
|
||||||
self._set_mode(ConsoleMode.INACTIVE)
|
|
||||||
|
|
||||||
def _set_mode(self, mode: ConsoleMode):
|
|
||||||
"""
|
|
||||||
Set the mode of the web console.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
mode (ConsoleMode): The mode to set.
|
|
||||||
"""
|
|
||||||
|
|
||||||
match mode:
|
|
||||||
case ConsoleMode.ACTIVE:
|
|
||||||
if self.term:
|
|
||||||
if self._term_layout.indexOf(self.term) == -1: # type: ignore[arg-type]
|
|
||||||
self._term_layout.addWidget(self.term) # type: ignore # BecTerminal is QWidget
|
|
||||||
self.term.show() # type: ignore[attr-defined]
|
|
||||||
self._stacked_layout.setCurrentIndex(0)
|
|
||||||
self._mode = mode
|
|
||||||
else:
|
|
||||||
self._stacked_layout.setCurrentIndex(1)
|
|
||||||
self._mode = ConsoleMode.INACTIVE
|
|
||||||
case ConsoleMode.INACTIVE:
|
|
||||||
self._stacked_layout.setCurrentIndex(1)
|
|
||||||
self._mode = mode
|
|
||||||
case ConsoleMode.HIDDEN:
|
|
||||||
self._stacked_layout.setCurrentIndex(1)
|
|
||||||
self._mode = mode
|
|
||||||
|
|
||||||
@property
|
|
||||||
def startup_cmd(self):
|
|
||||||
"""
|
|
||||||
Get the startup command for the web console.
|
|
||||||
"""
|
|
||||||
return self._startup_cmd
|
|
||||||
|
|
||||||
@startup_cmd.setter
|
|
||||||
def startup_cmd(self, cmd: str | None):
|
|
||||||
"""
|
|
||||||
Set the startup command for the console.
|
|
||||||
"""
|
|
||||||
self._startup_cmd = cmd
|
|
||||||
|
|
||||||
def write(self, data: str, send_return: bool = True):
|
|
||||||
"""
|
|
||||||
Send data to the console
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data (str): The data to send.
|
|
||||||
send_return (bool): Whether to send a return after the data.
|
|
||||||
"""
|
|
||||||
if self.term:
|
|
||||||
self.term.write(data, send_return)
|
|
||||||
|
|
||||||
def _ensure_startup_started(self):
|
|
||||||
if not self.startup_cmd or not _bec_console_registry.should_initialize(self):
|
|
||||||
return
|
|
||||||
self.write(self.startup_cmd, True)
|
|
||||||
_bec_console_registry.mark_initialized(self)
|
|
||||||
|
|
||||||
def _detach_terminal_widget(self, term: BecTerminal | None) -> bool:
|
|
||||||
if term is None or not BecConsoleRegistry._is_valid_qobject(term):
|
|
||||||
if self.term is term:
|
|
||||||
self.term = None
|
|
||||||
return False
|
|
||||||
|
|
||||||
is_child = self.isAncestorOf(term) # type: ignore[arg-type]
|
|
||||||
if self._term_layout.indexOf(term) != -1: # type: ignore[arg-type]
|
|
||||||
self._term_layout.removeWidget(term) # type: ignore[arg-type]
|
|
||||||
is_child = True
|
|
||||||
if is_child:
|
|
||||||
term.hide() # type: ignore[attr-defined]
|
|
||||||
term.setParent(None) # type: ignore[attr-defined]
|
|
||||||
if self.term is term:
|
|
||||||
self.term = None
|
|
||||||
return is_child
|
|
||||||
|
|
||||||
def take_terminal_ownership(self):
|
|
||||||
"""
|
|
||||||
Take ownership of a web instance from the registry. This will transfer the instance
|
|
||||||
from its current owner (if any) to this widget.
|
|
||||||
"""
|
|
||||||
# Get the instance from registry
|
|
||||||
self.term = _bec_console_registry.take_ownership(self)
|
|
||||||
self._infer_mode()
|
|
||||||
self._ensure_startup_started()
|
|
||||||
if self._mode == ConsoleMode.ACTIVE:
|
|
||||||
logger.debug(f"Widget {self.gui_id} took ownership of instance {self.terminal_id}")
|
|
||||||
|
|
||||||
def yield_ownership(self):
|
|
||||||
"""
|
|
||||||
Yield ownership of the instance. The instance remains in the registry with no owner,
|
|
||||||
available for another widget to claim. This is automatically called when the
|
|
||||||
widget becomes hidden.
|
|
||||||
"""
|
|
||||||
_bec_console_registry.yield_ownership(self)
|
|
||||||
self._infer_mode()
|
|
||||||
if self._mode != ConsoleMode.ACTIVE:
|
|
||||||
logger.debug(f"Widget {self.gui_id} yielded ownership of instance {self.terminal_id}")
|
|
||||||
|
|
||||||
def hideEvent(self, event):
|
|
||||||
"""Called when the widget is hidden. Automatically yields ownership."""
|
|
||||||
self.yield_ownership()
|
|
||||||
super().hideEvent(event)
|
|
||||||
|
|
||||||
def showEvent(self, event):
|
|
||||||
"""Called when the widget is shown. Updates UI state based on ownership."""
|
|
||||||
super().showEvent(event)
|
|
||||||
if not _bec_console_registry.is_owner(self):
|
|
||||||
if not _bec_console_registry.owner_is_visible(self.terminal_id):
|
|
||||||
self.take_terminal_ownership()
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
"""Unregister this console on destruction."""
|
|
||||||
_bec_console_registry.unregister(self)
|
|
||||||
super().cleanup()
|
|
||||||
|
|
||||||
|
|
||||||
class BECShell(BecConsole):
|
|
||||||
"""
|
|
||||||
A BecConsole pre-configured to run the BEC shell.
|
|
||||||
We cannot simply expose the web console properties to Qt as we need to have a deterministic
|
|
||||||
startup behavior for sharing the same shell instance across multiple widgets.
|
|
||||||
"""
|
|
||||||
|
|
||||||
ICON_NAME = "hub"
|
|
||||||
persist_terminal_session = True
|
|
||||||
|
|
||||||
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
|
|
||||||
super().__init__(
|
|
||||||
parent=parent,
|
|
||||||
config=config,
|
|
||||||
client=client,
|
|
||||||
gui_id=gui_id,
|
|
||||||
terminal_id="bec_shell",
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def startup_cmd(self):
|
|
||||||
"""
|
|
||||||
Get the startup command for the BEC shell.
|
|
||||||
"""
|
|
||||||
if self.bec_dispatcher.cli_server is None:
|
|
||||||
return "bec --nogui"
|
|
||||||
return f"bec --gui-id {self.bec_dispatcher.cli_server.gui_id}"
|
|
||||||
|
|
||||||
@startup_cmd.setter
|
|
||||||
def startup_cmd(self, cmd: str | None): ...
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
|
||||||
widget = QTabWidget()
|
|
||||||
|
|
||||||
# Create two consoles with different unique_ids
|
|
||||||
bec_console_1a = BecConsole(startup_cmd="htop", gui_id="console_1_a", terminal_id="terminal_1")
|
|
||||||
bec_console_1b = BecConsole(startup_cmd="htop", gui_id="console_1_b", terminal_id="terminal_1")
|
|
||||||
bec_console_1 = QWidget()
|
|
||||||
bec_console_1_layout = QHBoxLayout(bec_console_1)
|
|
||||||
bec_console_1_layout.addWidget(bec_console_1a)
|
|
||||||
bec_console_1_layout.addWidget(bec_console_1b)
|
|
||||||
bec_console2 = BECShell()
|
|
||||||
bec_console3 = BecConsole(gui_id="console_3", terminal_id="terminal_1")
|
|
||||||
widget.addTab(bec_console_1, "Console 1")
|
|
||||||
widget.addTab(bec_console2, "Console 2 - BEC Shell")
|
|
||||||
widget.addTab(bec_console3, "Console 3 -- mirror of Console 1")
|
|
||||||
widget.show()
|
|
||||||
|
|
||||||
widget.resize(800, 600)
|
|
||||||
|
|
||||||
sys.exit(app.exec_())
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{'files': ['bec_console.py']}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{'files': ['bec_console.py']}
|
|
||||||
@@ -47,13 +47,15 @@ class BECJupyterConsole(RichJupyterWidget): # pragma: no cover:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _init_bec_kernel(self):
|
def _init_bec_kernel(self):
|
||||||
self.execute("""
|
self.execute(
|
||||||
|
"""
|
||||||
from bec_ipython_client.main import BECIPythonClient
|
from bec_ipython_client.main import BECIPythonClient
|
||||||
bec = BECIPythonClient()
|
bec = BECIPythonClient()
|
||||||
bec.start()
|
bec.start()
|
||||||
dev = bec.device_manager.devices if bec else None
|
dev = bec.device_manager.devices if bec else None
|
||||||
scans = bec.scans if bec else None
|
scans = bec.scans if bec else None
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
def _cleanup_bec(self):
|
def _cleanup_bec(self):
|
||||||
if getattr(self, "ipyclient", None) is not None and self.inprocess is True:
|
if getattr(self, "ipyclient", None) is not None and self.inprocess is True:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from typing import Any, cast
|
|||||||
|
|
||||||
from bec_lib.logger import bec_logger
|
from bec_lib.logger import bec_logger
|
||||||
from bec_lib.macro_update_handler import has_executable_code
|
from bec_lib.macro_update_handler import has_executable_code
|
||||||
from qtpy.QtCore import Signal
|
from qtpy.QtCore import QEvent, QTimer, Signal
|
||||||
from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QWidget
|
from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QWidget
|
||||||
|
|
||||||
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
|
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
|
||||||
@@ -36,12 +36,12 @@ class MonacoDock(DockAreaWidget):
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
self.dock_manager.focusedDockWidgetChanged.connect(self._on_focus_event)
|
self.dock_manager.focusedDockWidgetChanged.connect(self._on_focus_event)
|
||||||
|
self.dock_manager.installEventFilter(self)
|
||||||
self._last_focused_editor: CDockWidget | None = None
|
self._last_focused_editor: CDockWidget | None = None
|
||||||
self.focused_editor.connect(self._on_last_focused_editor_changed)
|
self.focused_editor.connect(self._on_last_focused_editor_changed)
|
||||||
initial_editor = self.add_editor()
|
initial_editor = self.add_editor()
|
||||||
if isinstance(initial_editor, CDockWidget):
|
if isinstance(initial_editor, CDockWidget):
|
||||||
self.last_focused_editor = initial_editor
|
self.last_focused_editor = initial_editor
|
||||||
self._install_manager_scan_and_fix_guards()
|
|
||||||
|
|
||||||
def _create_editor_widget(self) -> MonacoWidget:
|
def _create_editor_widget(self) -> MonacoWidget:
|
||||||
"""Create a configured Monaco editor widget."""
|
"""Create a configured Monaco editor widget."""
|
||||||
@@ -73,8 +73,7 @@ class MonacoDock(DockAreaWidget):
|
|||||||
logger.info(f"Editor '{widget.current_file}' has unsaved changes: {widget.get_text()}")
|
logger.info(f"Editor '{widget.current_file}' has unsaved changes: {widget.get_text()}")
|
||||||
self.save_enabled.emit(widget.modified)
|
self.save_enabled.emit(widget.modified)
|
||||||
|
|
||||||
@staticmethod
|
def _update_tab_title_for_modification(self, dock: CDockWidget, modified: bool):
|
||||||
def _update_tab_title_for_modification(dock: CDockWidget, modified: bool):
|
|
||||||
"""Update the tab title to show modification status with a dot indicator."""
|
"""Update the tab title to show modification status with a dot indicator."""
|
||||||
current_title = dock.windowTitle()
|
current_title = dock.windowTitle()
|
||||||
|
|
||||||
@@ -99,12 +98,14 @@ class MonacoDock(DockAreaWidget):
|
|||||||
return
|
return
|
||||||
|
|
||||||
active_sig = signatures[signature.get("activeSignature", 0)]
|
active_sig = signatures[signature.get("activeSignature", 0)]
|
||||||
|
active_param = signature.get("activeParameter", 0) # TODO: Add highlight for active_param
|
||||||
|
|
||||||
# Get signature label and documentation
|
# Get signature label and documentation
|
||||||
label = active_sig.get("label", "")
|
label = active_sig.get("label", "")
|
||||||
doc_obj = active_sig.get("documentation", {})
|
doc_obj = active_sig.get("documentation", {})
|
||||||
documentation = doc_obj.get("value", "") if isinstance(doc_obj, dict) else str(doc_obj)
|
documentation = doc_obj.get("value", "") if isinstance(doc_obj, dict) else str(doc_obj)
|
||||||
|
|
||||||
# Format the Markdown output
|
# Format the markdown output
|
||||||
markdown = f"```python\n{label}\n```\n\n{documentation}"
|
markdown = f"```python\n{label}\n```\n\n{documentation}"
|
||||||
self.signature_help.emit(markdown)
|
self.signature_help.emit(markdown)
|
||||||
|
|
||||||
@@ -155,10 +156,9 @@ class MonacoDock(DockAreaWidget):
|
|||||||
if self.last_focused_editor is dock:
|
if self.last_focused_editor is dock:
|
||||||
self.last_focused_editor = None
|
self.last_focused_editor = None
|
||||||
# After topology changes, make sure single-tab areas get a plus button
|
# After topology changes, make sure single-tab areas get a plus button
|
||||||
self._scan_and_fix_areas()
|
QTimer.singleShot(0, self._scan_and_fix_areas)
|
||||||
|
|
||||||
@staticmethod
|
def reset_widget(self, widget: MonacoWidget):
|
||||||
def reset_widget(widget: MonacoWidget):
|
|
||||||
"""
|
"""
|
||||||
Reset the given Monaco editor widget to its initial state.
|
Reset the given Monaco editor widget to its initial state.
|
||||||
|
|
||||||
@@ -193,23 +193,23 @@ class MonacoDock(DockAreaWidget):
|
|||||||
# pylint: disable=protected-access
|
# pylint: disable=protected-access
|
||||||
area._monaco_plus_btn = plus_btn
|
area._monaco_plus_btn = plus_btn
|
||||||
|
|
||||||
def _install_manager_scan_and_fix_guards(self) -> None:
|
def _scan_and_fix_areas(self):
|
||||||
"""
|
|
||||||
Track ADS structural changes to trigger scan and fix of dock areas for plus button injection.
|
|
||||||
"""
|
|
||||||
self.dock_manager.dockAreaCreated.connect(self._scan_and_fix_areas)
|
|
||||||
self.dock_manager.dockWidgetAdded.connect(self._scan_and_fix_areas)
|
|
||||||
self.dock_manager.stateRestored.connect(self._scan_and_fix_areas)
|
|
||||||
self.dock_manager.restoringState.connect(self._scan_and_fix_areas)
|
|
||||||
self.dock_manager.focusedDockWidgetChanged.connect(self._scan_and_fix_areas)
|
|
||||||
self._scan_and_fix_areas()
|
|
||||||
|
|
||||||
def _scan_and_fix_areas(self, *_arg):
|
|
||||||
# Find all dock areas under this manager and ensure each single-tab area has a plus button
|
# Find all dock areas under this manager and ensure each single-tab area has a plus button
|
||||||
areas = self.dock_manager.findChildren(CDockAreaWidget)
|
areas = self.dock_manager.findChildren(CDockAreaWidget)
|
||||||
for a in areas:
|
for a in areas:
|
||||||
self._ensure_area_plus(a)
|
self._ensure_area_plus(a)
|
||||||
|
|
||||||
|
def eventFilter(self, obj, event):
|
||||||
|
# Track dock manager events
|
||||||
|
if obj is self.dock_manager and event.type() in (
|
||||||
|
QEvent.Type.ChildAdded,
|
||||||
|
QEvent.Type.ChildRemoved,
|
||||||
|
QEvent.Type.LayoutRequest,
|
||||||
|
):
|
||||||
|
QTimer.singleShot(0, self._scan_and_fix_areas)
|
||||||
|
|
||||||
|
return super().eventFilter(obj, event)
|
||||||
|
|
||||||
def add_editor(
|
def add_editor(
|
||||||
self, area: Any | None = None, title: str | None = None, tooltip: str | None = None
|
self, area: Any | None = None, title: str | None = None, tooltip: str | None = None
|
||||||
) -> CDockWidget:
|
) -> CDockWidget:
|
||||||
@@ -258,7 +258,7 @@ class MonacoDock(DockAreaWidget):
|
|||||||
if area_widget is not None:
|
if area_widget is not None:
|
||||||
self._ensure_area_plus(area_widget)
|
self._ensure_area_plus(area_widget)
|
||||||
|
|
||||||
self._scan_and_fix_areas()
|
QTimer.singleShot(0, self._scan_and_fix_areas)
|
||||||
self.last_focused_editor = dock
|
self.last_focused_editor = dock
|
||||||
return dock
|
return dock
|
||||||
|
|
||||||
|
|||||||
@@ -362,7 +362,8 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
widget.set_language("python")
|
widget.set_language("python")
|
||||||
widget.set_theme("vs-dark")
|
widget.set_theme("vs-dark")
|
||||||
widget.editor.set_minimap_enabled(False)
|
widget.editor.set_minimap_enabled(False)
|
||||||
widget.set_text("""
|
widget.set_text(
|
||||||
|
"""
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
@@ -379,7 +380,8 @@ if TYPE_CHECKING:
|
|||||||
# This is a comment
|
# This is a comment
|
||||||
def hello_world():
|
def hello_world():
|
||||||
print("Hello, world!")
|
print("Hello, world!")
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
widget.set_highlighted_lines(1, 3)
|
widget.set_highlighted_lines(1, 3)
|
||||||
widget.show()
|
widget.show()
|
||||||
qapp.exec_()
|
qapp.exec_()
|
||||||
|
|||||||
@@ -53,8 +53,6 @@ class ScanMetadata(PydanticModelForm):
|
|||||||
|
|
||||||
super().__init__(parent=parent, data_model=self._md_schema, client=client, **kwargs)
|
super().__init__(parent=parent, data_model=self._md_schema, client=client, **kwargs)
|
||||||
|
|
||||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
|
||||||
self._form_grid_container.layout().setContentsMargins(0, 0, 0, 0)
|
|
||||||
self._layout.addWidget(self._additional_md_box)
|
self._layout.addWidget(self._additional_md_box)
|
||||||
self._additional_md_box_layout.addWidget(self._additional_metadata)
|
self._additional_md_box_layout.addWidget(self._additional_metadata)
|
||||||
|
|
||||||
@@ -80,27 +78,12 @@ class ScanMetadata(PydanticModelForm):
|
|||||||
|
|
||||||
def get_form_data(self):
|
def get_form_data(self):
|
||||||
"""Get the entered metadata as a dict"""
|
"""Get the entered metadata as a dict"""
|
||||||
form_data = self._additional_metadata.dump_dict() | self._dict_from_grid()
|
return self._additional_metadata.dump_dict() | self._dict_from_grid()
|
||||||
|
|
||||||
# If scan_name is empty, set it to the current scan
|
|
||||||
if "scan_name" in form_data and not form_data["scan_name"]:
|
|
||||||
form_data["scan_name"] = self._scan_name
|
|
||||||
|
|
||||||
return form_data
|
|
||||||
|
|
||||||
def populate(self):
|
def populate(self):
|
||||||
self._additional_metadata.update_disallowed_keys(list(self._md_schema.model_fields.keys()))
|
self._additional_metadata.update_disallowed_keys(list(self._md_schema.model_fields.keys()))
|
||||||
super().populate()
|
super().populate()
|
||||||
|
|
||||||
# Set scan_name field to current scan if it exists and is empty
|
|
||||||
if "scan_name" not in self.widget_dict:
|
|
||||||
return
|
|
||||||
scan_name_widget = self.widget_dict["scan_name"]
|
|
||||||
if not hasattr(scan_name_widget, "getValue") or scan_name_widget.getValue():
|
|
||||||
return
|
|
||||||
if hasattr(scan_name_widget, "setValue"):
|
|
||||||
scan_name_widget.setValue(self._scan_name)
|
|
||||||
|
|
||||||
def set_schema_from_scan(self, scan_name: str | None):
|
def set_schema_from_scan(self, scan_name: str | None):
|
||||||
self._scan_name = scan_name or ""
|
self._scan_name = scan_name or ""
|
||||||
self.set_schema(get_metadata_schema_for_scan(self._scan_name))
|
self.set_schema(get_metadata_schema_for_scan(self._scan_name))
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user