mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-05-09 16:22:08 +02:00
Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1d5069a48 | |||
| 0b1f0b4c26 | |||
| cc825972c2 | |||
| 17865a2c33 | |||
| 0728811238 | |||
| 717d74b19e | |||
| dd32caf6e8 | |||
| 603edede9c | |||
| 30ef25533a | |||
| 73b44cffb2 | |||
| a614d662d6 | |||
| 3f1aa80756 | |||
| 409c9e5bfa | |||
| 19b5c8f724 | |||
| 5056ef8946 | |||
| 551d38d901 | |||
| 999b7a2321 | |||
| 5dc373bd8e | |||
| 91afc775d5 | |||
| 55694ff2b9 | |||
| 5b68a51aaa | |||
| f13fa75e25 | |||
| 0cf84cd1d8 | |||
| 3e77f54034 | |||
| f7616102d8 | |||
| 5a497c3598 | |||
| 23e3644619 | |||
| a5db2dc340 | |||
| 2e8f43fcac | |||
| 09bb1121d8 | |||
| c9aaa77b3c | |||
| f7a1ee49a4 | |||
| 8e51c1adb6 | |||
| 846b6e6968 | |||
| f562c61e3c | |||
| bda5d38965 | |||
| 9b0ec9dd79 | |||
| 1754e759f0 | |||
| 308e84d0e1 | |||
| fa2ef83bb9 | |||
| 02cb393bb0 | |||
| 1d3e0214fd | |||
| 37747babda | |||
| 32f5d486d3 | |||
| 0ff1fdc815 | |||
| c7de320ca5 | |||
| 5b23dce3d0 | |||
| 5e84d3bec6 | |||
| 9a2396ee9c | |||
| 2dab16b684 | |||
| e6c8cd0b1a | |||
| 242f8933b2 | |||
| 83ac6bcd37 | |||
| 90ecd8ea87 | |||
| 6e5f6e7fbb | |||
| 2f75aaea16 | |||
| 677550931b | |||
| 96b5179658 | |||
| e25b6604d1 | |||
| f74c5a4516 | |||
| a2923752c2 | |||
| a486c52058 | |||
| 31389a3dd0 | |||
| 1676efc1ea | |||
| 05c38d9b82 | |||
| f67b60ac98 | |||
| 5ec59d5dbb | |||
| d46ffb59f0 | |||
| da400d20b6 | |||
| 20f06d8659 | |||
| 3d29a67c0b | |||
| e7ef8a3891 | |||
| 90222f3082 | |||
| 79af15a88b |
@@ -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,pyside6]
|
uv pip install --system -e ./bec_widgets[dev,qtermwidget]
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
##########################
|
||||||
|
### AI-generated file. ###
|
||||||
|
##########################
|
||||||
|
|
||||||
|
"""Aggregate and merge benchmark JSON files.
|
||||||
|
|
||||||
|
The workflow runs the same benchmark suite on multiple independent runners.
|
||||||
|
This script reads every JSON file produced by those attempts, normalizes the
|
||||||
|
contained benchmark values, and writes a compact mapping JSON where each value is
|
||||||
|
the median across attempts. It can also merge independent hyperfine JSON files
|
||||||
|
from one runner into a single hyperfine-style JSON file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import statistics
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from compare_benchmarks import Benchmark, extract_benchmarks
|
||||||
|
|
||||||
|
|
||||||
|
def collect_benchmarks(paths: list[Path]) -> dict[str, list[Benchmark]]:
|
||||||
|
"""Collect benchmarks from multiple JSON files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
paths (list[Path]): Paths to hyperfine, pytest-benchmark, or compact
|
||||||
|
mapping JSON files.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, list[Benchmark]]: Benchmarks grouped by benchmark name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
collected: dict[str, list[Benchmark]] = {}
|
||||||
|
for path in paths:
|
||||||
|
for name, benchmark in extract_benchmarks(path).items():
|
||||||
|
collected.setdefault(name, []).append(benchmark)
|
||||||
|
return collected
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate(collected: dict[str, list[Benchmark]]) -> dict[str, dict[str, object]]:
|
||||||
|
"""Aggregate grouped benchmarks using the median value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
collected (dict[str, list[Benchmark]]): Benchmarks grouped by benchmark
|
||||||
|
name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, dict[str, object]]: Compact mapping JSON data. Each benchmark
|
||||||
|
contains ``value``, ``unit``, ``metric``, ``attempts``, and
|
||||||
|
``attempt_values``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
aggregated: dict[str, dict[str, object]] = {}
|
||||||
|
for name, benchmarks in sorted(collected.items()):
|
||||||
|
values = [benchmark.value for benchmark in benchmarks]
|
||||||
|
unit = next((benchmark.unit for benchmark in benchmarks if benchmark.unit), "")
|
||||||
|
metric = next((benchmark.metric for benchmark in benchmarks if benchmark.metric), "value")
|
||||||
|
aggregated[name] = {
|
||||||
|
"value": statistics.median(values),
|
||||||
|
"unit": unit,
|
||||||
|
"metric": f"median-of-attempt-{metric}",
|
||||||
|
"attempts": len(values),
|
||||||
|
"attempt_values": values,
|
||||||
|
}
|
||||||
|
return aggregated
|
||||||
|
|
||||||
|
|
||||||
|
def merge_hyperfine_results(paths: list[Path]) -> dict[str, Any]:
|
||||||
|
"""Merge hyperfine result files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
paths (list[Path]): Hyperfine JSON files to merge.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Any]: Hyperfine-style JSON object containing all result rows.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If any file has no hyperfine ``results`` list.
|
||||||
|
"""
|
||||||
|
|
||||||
|
merged: dict[str, Any] = {"results": []}
|
||||||
|
for path in paths:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
results = data.get("results", []) if isinstance(data, dict) else None
|
||||||
|
if not isinstance(results, list):
|
||||||
|
raise ValueError(f"{path} has no hyperfine results list")
|
||||||
|
merged["results"].extend(results)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def main_from_paths(input_dir: Path, output: Path) -> int:
|
||||||
|
"""Aggregate all JSON files in a directory and write the result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_dir (Path): Directory containing benchmark JSON files.
|
||||||
|
output (Path): Path where the aggregate JSON should be written.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Always ``0`` on success.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If no JSON files are found in ``input_dir``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
paths = sorted(input_dir.rglob("*.json"))
|
||||||
|
if not paths:
|
||||||
|
raise ValueError(f"No benchmark JSON files found in {input_dir}")
|
||||||
|
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output.write_text(
|
||||||
|
json.dumps(aggregate(collect_benchmarks(paths)), indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def merge_from_paths(input_dir: Path, output: Path) -> int:
|
||||||
|
"""Merge all hyperfine JSON files in a directory and write the result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_dir (Path): Directory containing hyperfine JSON files.
|
||||||
|
output (Path): Path where the merged JSON should be written.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Always ``0`` on success.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If no JSON files are found in ``input_dir``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
paths = sorted(input_dir.glob("*.json"))
|
||||||
|
if not paths:
|
||||||
|
raise ValueError(f"No hyperfine JSON files found in {input_dir}")
|
||||||
|
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output.write_text(
|
||||||
|
json.dumps(merge_hyperfine_results(paths), indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Run the benchmark aggregation command line interface.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Always ``0`` on success.
|
||||||
|
"""
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
"--mode",
|
||||||
|
choices=("aggregate", "merge-hyperfine"),
|
||||||
|
default="aggregate",
|
||||||
|
help="Operation to perform.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--input-dir", required=True, type=Path)
|
||||||
|
parser.add_argument("--output", required=True, type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.mode == "merge-hyperfine":
|
||||||
|
return merge_from_paths(input_dir=args.input_dir, output=args.output)
|
||||||
|
return main_from_paths(input_dir=args.input_dir, output=args.output)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
##########################
|
||||||
|
### AI-generated file. ###
|
||||||
|
##########################
|
||||||
|
|
||||||
|
"""Compare benchmark JSON files and write a GitHub Actions summary.
|
||||||
|
|
||||||
|
The script supports JSON emitted by hyperfine, JSON emitted by pytest-benchmark,
|
||||||
|
and a compact mapping format generated by ``aggregate_benchmarks.py``. Timing
|
||||||
|
formats prefer median values and fall back to mean values when median values are
|
||||||
|
not present.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Benchmark:
|
||||||
|
"""Normalized benchmark result.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name (str): Stable benchmark name used to match baseline and current results.
|
||||||
|
value (float): Numeric benchmark value used for comparison.
|
||||||
|
unit (str): Display unit for the value, for example ``"s"``.
|
||||||
|
metric (str): Source metric name, for example ``"median"`` or ``"mean"``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
value: float
|
||||||
|
unit: str
|
||||||
|
metric: str = "value"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Comparison:
|
||||||
|
"""Comparison between one baseline benchmark and one current benchmark.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name (str): Benchmark name.
|
||||||
|
baseline (float): Baseline benchmark value.
|
||||||
|
current (float): Current benchmark value.
|
||||||
|
delta_percent (float): Percent change from baseline to current.
|
||||||
|
unit (str): Display unit for both values.
|
||||||
|
metric (str): Current result metric used for comparison.
|
||||||
|
regressed (bool): Whether the change exceeds the configured threshold in
|
||||||
|
the worse direction.
|
||||||
|
improved (bool): Whether the change exceeds the configured threshold in
|
||||||
|
the better direction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
baseline: float
|
||||||
|
current: float
|
||||||
|
delta_percent: float
|
||||||
|
unit: str
|
||||||
|
metric: str
|
||||||
|
regressed: bool
|
||||||
|
improved: bool
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json(path: Path) -> Any:
|
||||||
|
"""Read JSON data from a file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path (Path): Path to the JSON file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Any: Parsed JSON value.
|
||||||
|
"""
|
||||||
|
|
||||||
|
with path.open("r", encoding="utf-8") as stream:
|
||||||
|
return json.load(stream)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_float(value: Any) -> float | None:
|
||||||
|
"""Convert a value to a finite float.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value (Any): Value to convert.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float | None: Converted finite float, or ``None`` if conversion fails.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if math.isfinite(result):
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_hyperfine(data: dict[str, Any]) -> dict[str, Benchmark]:
|
||||||
|
"""Extract normalized benchmarks from hyperfine JSON.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data (dict[str, Any]): Parsed hyperfine JSON object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Benchmark]: Benchmarks keyed by command name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
benchmarks: dict[str, Benchmark] = {}
|
||||||
|
for result in data.get("results", []):
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
continue
|
||||||
|
name = str(result.get("command") or result.get("name") or "").strip()
|
||||||
|
metric = "median"
|
||||||
|
value = _as_float(result.get(metric))
|
||||||
|
if value is None:
|
||||||
|
metric = "mean"
|
||||||
|
value = _as_float(result.get(metric))
|
||||||
|
if name and value is not None:
|
||||||
|
benchmarks[name] = Benchmark(name=name, value=value, unit="s", metric=metric)
|
||||||
|
return benchmarks
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_pytest_benchmark(data: dict[str, Any]) -> dict[str, Benchmark]:
|
||||||
|
"""Extract normalized benchmarks from pytest-benchmark JSON.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data (dict[str, Any]): Parsed pytest-benchmark JSON object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Benchmark]: Benchmarks keyed by full benchmark name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
benchmarks: dict[str, Benchmark] = {}
|
||||||
|
for benchmark in data.get("benchmarks", []):
|
||||||
|
if not isinstance(benchmark, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = str(benchmark.get("fullname") or benchmark.get("name") or "").strip()
|
||||||
|
stats = benchmark.get("stats", {})
|
||||||
|
value = None
|
||||||
|
metric = "median"
|
||||||
|
if isinstance(stats, dict):
|
||||||
|
value = _as_float(stats.get(metric))
|
||||||
|
if value is None:
|
||||||
|
metric = "mean"
|
||||||
|
value = _as_float(stats.get(metric))
|
||||||
|
if name and value is not None:
|
||||||
|
benchmarks[name] = Benchmark(name=name, value=value, unit="s", metric=metric)
|
||||||
|
return benchmarks
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_simple_mapping(data: dict[str, Any]) -> dict[str, Benchmark]:
|
||||||
|
"""Extract normalized benchmarks from a compact mapping JSON object.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data (dict[str, Any]): Parsed mapping where each benchmark is either a
|
||||||
|
raw number or an object containing ``value``, ``unit``, and ``metric``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Benchmark]: Benchmarks keyed by mapping key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
benchmarks: dict[str, Benchmark] = {}
|
||||||
|
|
||||||
|
for name, raw_value in data.items():
|
||||||
|
if name in {"version", "context", "commit", "timestamp"}:
|
||||||
|
continue
|
||||||
|
|
||||||
|
value = _as_float(raw_value)
|
||||||
|
unit = ""
|
||||||
|
metric = "value"
|
||||||
|
if value is None and isinstance(raw_value, dict):
|
||||||
|
value = _as_float(raw_value.get("value"))
|
||||||
|
unit = str(raw_value.get("unit") or "")
|
||||||
|
metric = str(raw_value.get("metric") or "value")
|
||||||
|
|
||||||
|
if value is not None:
|
||||||
|
benchmarks[str(name)] = Benchmark(name=str(name), value=value, unit=unit, metric=metric)
|
||||||
|
|
||||||
|
return benchmarks
|
||||||
|
|
||||||
|
|
||||||
|
def extract_benchmarks(path: Path) -> dict[str, Benchmark]:
|
||||||
|
"""Extract normalized benchmarks from a supported JSON file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path (Path): Path to a hyperfine, pytest-benchmark, or compact mapping
|
||||||
|
JSON file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Benchmark]: Normalized benchmarks keyed by name.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the JSON root is not an object or no supported benchmark
|
||||||
|
entries can be extracted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = _read_json(path)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError(f"{path} must contain a JSON object")
|
||||||
|
|
||||||
|
extractors = (_extract_hyperfine, _extract_pytest_benchmark, _extract_simple_mapping)
|
||||||
|
for extractor in extractors:
|
||||||
|
benchmarks = extractor(data)
|
||||||
|
if benchmarks:
|
||||||
|
return benchmarks
|
||||||
|
|
||||||
|
raise ValueError(f"No supported benchmark entries found in {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def compare_benchmarks(
|
||||||
|
baseline: dict[str, Benchmark],
|
||||||
|
current: dict[str, Benchmark],
|
||||||
|
threshold_percent: float,
|
||||||
|
higher_is_better: bool,
|
||||||
|
) -> tuple[list[Comparison], list[str], list[str]]:
|
||||||
|
"""Compare baseline benchmarks with current benchmarks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
baseline (dict[str, Benchmark]): Baseline benchmarks keyed by name.
|
||||||
|
current (dict[str, Benchmark]): Current benchmarks keyed by name.
|
||||||
|
threshold_percent (float): Regression threshold in percent.
|
||||||
|
higher_is_better (bool): If ``True``, lower current values are treated as
|
||||||
|
regressions. If ``False``, higher current values are treated as
|
||||||
|
regressions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[list[Comparison], list[str], list[str]]: Comparisons for common
|
||||||
|
benchmark names, names missing from current results, and names newly
|
||||||
|
present in current results.
|
||||||
|
"""
|
||||||
|
|
||||||
|
comparisons: list[Comparison] = []
|
||||||
|
missing_in_current: list[str] = []
|
||||||
|
new_in_current: list[str] = []
|
||||||
|
|
||||||
|
for name, baseline_benchmark in sorted(baseline.items()):
|
||||||
|
current_benchmark = current.get(name)
|
||||||
|
if current_benchmark is None:
|
||||||
|
missing_in_current.append(name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if baseline_benchmark.value == 0:
|
||||||
|
delta_percent = 0.0
|
||||||
|
else:
|
||||||
|
delta_percent = (
|
||||||
|
(current_benchmark.value - baseline_benchmark.value)
|
||||||
|
/ abs(baseline_benchmark.value)
|
||||||
|
* 100
|
||||||
|
)
|
||||||
|
|
||||||
|
if higher_is_better:
|
||||||
|
regressed = delta_percent <= -threshold_percent
|
||||||
|
improved = delta_percent >= threshold_percent
|
||||||
|
else:
|
||||||
|
regressed = delta_percent >= threshold_percent
|
||||||
|
improved = delta_percent <= -threshold_percent
|
||||||
|
|
||||||
|
comparisons.append(
|
||||||
|
Comparison(
|
||||||
|
name=name,
|
||||||
|
baseline=baseline_benchmark.value,
|
||||||
|
current=current_benchmark.value,
|
||||||
|
delta_percent=delta_percent,
|
||||||
|
unit=current_benchmark.unit or baseline_benchmark.unit,
|
||||||
|
metric=current_benchmark.metric,
|
||||||
|
regressed=regressed,
|
||||||
|
improved=improved,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for name in sorted(set(current) - set(baseline)):
|
||||||
|
new_in_current.append(name)
|
||||||
|
|
||||||
|
return comparisons, missing_in_current, new_in_current
|
||||||
|
|
||||||
|
|
||||||
|
def _format_value(value: float, unit: str) -> str:
|
||||||
|
"""Format a benchmark value for Markdown output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value (float): Numeric benchmark value.
|
||||||
|
unit (str): Display unit.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Formatted value with optional unit suffix.
|
||||||
|
"""
|
||||||
|
|
||||||
|
suffix = f" {unit}" if unit else ""
|
||||||
|
return f"{value:.6g}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_status(comparison: Comparison) -> str:
|
||||||
|
"""Format a comparison status for Markdown output."""
|
||||||
|
|
||||||
|
if comparison.regressed:
|
||||||
|
return ":red_circle: regressed"
|
||||||
|
if comparison.improved:
|
||||||
|
return ":green_circle: improved"
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def write_summary(
|
||||||
|
path: Path,
|
||||||
|
comparisons: list[Comparison],
|
||||||
|
missing_in_current: list[str],
|
||||||
|
new_in_current: list[str],
|
||||||
|
threshold_percent: float,
|
||||||
|
higher_is_better: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Write a Markdown benchmark comparison summary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path (Path): Path where the summary should be written.
|
||||||
|
comparisons (list[Comparison]): Comparison rows for matching benchmarks.
|
||||||
|
missing_in_current (list[str]): Baseline benchmark names missing from the
|
||||||
|
current result.
|
||||||
|
new_in_current (list[str]): Current benchmark names not present in the
|
||||||
|
baseline result.
|
||||||
|
threshold_percent (float): Regression threshold in percent.
|
||||||
|
higher_is_better (bool): Whether higher benchmark values are considered
|
||||||
|
better.
|
||||||
|
"""
|
||||||
|
|
||||||
|
regressions = [comparison for comparison in comparisons if comparison.regressed]
|
||||||
|
improvements = [comparison for comparison in comparisons if comparison.improved]
|
||||||
|
direction = "higher is better" if higher_is_better else "lower is better"
|
||||||
|
sorted_comparisons = sorted(comparisons, key=lambda comparison: comparison.name)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"<!-- bw-benchmark-comment -->",
|
||||||
|
"## Benchmark comparison",
|
||||||
|
"",
|
||||||
|
f"Threshold: {threshold_percent:g}% ({direction}).",
|
||||||
|
f"Result: {len(regressions)} regression(s), {len(improvements)} improvement(s) beyond threshold.",
|
||||||
|
]
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if regressions:
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f"{len(regressions)} benchmark(s) regressed beyond the configured threshold.",
|
||||||
|
"",
|
||||||
|
"| Benchmark | Baseline | Current | Change |",
|
||||||
|
"| --- | ---: | ---: | ---: |",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for comparison in regressions:
|
||||||
|
lines.append(
|
||||||
|
"| "
|
||||||
|
f"{comparison.name} | "
|
||||||
|
f"{_format_value(comparison.baseline, comparison.unit)} | "
|
||||||
|
f"{_format_value(comparison.current, comparison.unit)} | "
|
||||||
|
f"{comparison.delta_percent:+.2f}% |"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append("No benchmark regression exceeded the configured threshold.")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if improvements:
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f"{len(improvements)} benchmark(s) improved beyond the configured threshold.",
|
||||||
|
"",
|
||||||
|
"| Benchmark | Baseline | Current | Change |",
|
||||||
|
"| --- | ---: | ---: | ---: |",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for comparison in improvements:
|
||||||
|
lines.append(
|
||||||
|
"| "
|
||||||
|
f"{comparison.name} | "
|
||||||
|
f"{_format_value(comparison.baseline, comparison.unit)} | "
|
||||||
|
f"{_format_value(comparison.current, comparison.unit)} | "
|
||||||
|
f"{comparison.delta_percent:+.2f}% |"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append("No benchmark improvement exceeded the configured threshold.")
|
||||||
|
|
||||||
|
if sorted_comparisons:
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"<details>",
|
||||||
|
"<summary>All benchmark results</summary>",
|
||||||
|
"",
|
||||||
|
"| Benchmark | Baseline | Current | Change | Status |",
|
||||||
|
"| --- | ---: | ---: | ---: | --- |",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for comparison in sorted_comparisons:
|
||||||
|
lines.append(
|
||||||
|
"| "
|
||||||
|
f"{comparison.name} | "
|
||||||
|
f"{_format_value(comparison.baseline, comparison.unit)} | "
|
||||||
|
f"{_format_value(comparison.current, comparison.unit)} | "
|
||||||
|
f"{comparison.delta_percent:+.2f}% | "
|
||||||
|
f"{_format_status(comparison)} |"
|
||||||
|
)
|
||||||
|
lines.extend(["", "</details>"])
|
||||||
|
|
||||||
|
if missing_in_current:
|
||||||
|
lines.extend(["", "Missing benchmarks in the current run:"])
|
||||||
|
lines.extend(f"- `{name}`" for name in missing_in_current)
|
||||||
|
|
||||||
|
if new_in_current:
|
||||||
|
lines.extend(["", "New benchmarks in the current run:"])
|
||||||
|
lines.extend(f"- `{name}`" for name in new_in_current)
|
||||||
|
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Run the benchmark comparison command line interface.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: ``1`` when a regression exceeds the threshold, otherwise ``0``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--baseline", required=True, type=Path)
|
||||||
|
parser.add_argument("--current", required=True, type=Path)
|
||||||
|
parser.add_argument("--summary", required=True, type=Path)
|
||||||
|
parser.add_argument("--threshold-percent", required=True, type=float)
|
||||||
|
parser.add_argument("--higher-is-better", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
baseline = extract_benchmarks(args.baseline)
|
||||||
|
current = extract_benchmarks(args.current)
|
||||||
|
comparisons, missing_in_current, new_in_current = compare_benchmarks(
|
||||||
|
baseline=baseline,
|
||||||
|
current=current,
|
||||||
|
threshold_percent=args.threshold_percent,
|
||||||
|
higher_is_better=args.higher_is_better,
|
||||||
|
)
|
||||||
|
|
||||||
|
write_summary(
|
||||||
|
path=args.summary,
|
||||||
|
comparisons=comparisons,
|
||||||
|
missing_in_current=missing_in_current,
|
||||||
|
new_in_current=new_in_current,
|
||||||
|
threshold_percent=args.threshold_percent,
|
||||||
|
higher_is_better=args.higher_is_better,
|
||||||
|
)
|
||||||
|
|
||||||
|
return 1 if any(comparison.regressed for comparison in comparisons) else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
##########################
|
||||||
|
### AI-generated file. ###
|
||||||
|
##########################
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
mkdir -p benchmark-results
|
||||||
|
benchmark_json="${BENCHMARK_JSON:-benchmark-results/current.json}"
|
||||||
|
benchmark_root="$(dirname "$benchmark_json")"
|
||||||
|
hyperfine_benchmark_dir="${BENCHMARK_HYPERFINE_DIR:-tests/benchmarks/hyperfine}"
|
||||||
|
pytest_benchmark_dirs="${BENCHMARK_PYTEST_DIRS:-${BENCHMARK_PYTEST_DIR:-}}"
|
||||||
|
benchmark_work_dir="$benchmark_root/raw-results"
|
||||||
|
hyperfine_json_dir="$benchmark_work_dir/hyperfine"
|
||||||
|
pytest_json="$benchmark_work_dir/pytest.json"
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
benchmark_scripts=()
|
||||||
|
benchmark_scripts=("$hyperfine_benchmark_dir"/benchmark_*.sh)
|
||||||
|
shopt -u nullglob
|
||||||
|
|
||||||
|
pytest_dirs=()
|
||||||
|
for pytest_benchmark_dir in $pytest_benchmark_dirs; do
|
||||||
|
if [ -d "$pytest_benchmark_dir" ]; then
|
||||||
|
pytest_dirs+=("$pytest_benchmark_dir")
|
||||||
|
else
|
||||||
|
echo "Pytest benchmark directory not found: $pytest_benchmark_dir" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "${#benchmark_scripts[@]}" -eq 0 ] && [ "${#pytest_dirs[@]}" -eq 0 ]; then
|
||||||
|
echo "No benchmark scripts or pytest benchmarks found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Benchmark Python: $(command -v python)"
|
||||||
|
python -c 'import sys; print(sys.version)'
|
||||||
|
|
||||||
|
rm -rf "$benchmark_work_dir"
|
||||||
|
mkdir -p "$hyperfine_json_dir"
|
||||||
|
|
||||||
|
if [ "${#benchmark_scripts[@]}" -gt 0 ]; then
|
||||||
|
for benchmark_script in "${benchmark_scripts[@]}"; do
|
||||||
|
title="$(sed -n 's/^# BENCHMARK_TITLE:[[:space:]]*//p' "$benchmark_script" | head -n 1)"
|
||||||
|
if [ -z "$title" ]; then
|
||||||
|
title="$(basename "$benchmark_script" .sh)"
|
||||||
|
fi
|
||||||
|
benchmark_name="$(basename "$benchmark_script" .sh)"
|
||||||
|
benchmark_result_json="$hyperfine_json_dir/$benchmark_name.json"
|
||||||
|
echo "Preflight benchmark script: $benchmark_script"
|
||||||
|
bash "$benchmark_script"
|
||||||
|
|
||||||
|
hyperfine \
|
||||||
|
--show-output \
|
||||||
|
--warmup 1 \
|
||||||
|
--runs 5 \
|
||||||
|
--command-name "$title" \
|
||||||
|
--export-json "$benchmark_result_json" \
|
||||||
|
"bash $(printf "%q" "$benchmark_script")"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${#pytest_dirs[@]}" -gt 0 ]; then
|
||||||
|
pytest \
|
||||||
|
-q "${pytest_dirs[@]}" \
|
||||||
|
--benchmark-only \
|
||||||
|
--benchmark-json "$pytest_json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
python .github/scripts/aggregate_benchmarks.py \
|
||||||
|
--input-dir "$benchmark_work_dir" \
|
||||||
|
--output "$benchmark_json"
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
##########################
|
||||||
|
### AI-generated file. ###
|
||||||
|
##########################
|
||||||
|
|
||||||
|
"""Run a command with BEC e2e services available."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import bec_lib
|
||||||
|
from bec_ipython_client import BECIPythonClient
|
||||||
|
from bec_lib.redis_connector import RedisConnector
|
||||||
|
from bec_lib.service_config import ServiceConfig, ServiceConfigModel
|
||||||
|
from redis import Redis
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_redis(host: str, port: int) -> None:
|
||||||
|
client = Redis(host=host, port=port)
|
||||||
|
deadline = time.monotonic() + 10
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
if client.ping():
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
time.sleep(0.1)
|
||||||
|
raise RuntimeError(f"Redis did not start on {host}:{port}")
|
||||||
|
|
||||||
|
|
||||||
|
def _start_redis(files_path: Path, host: str, port: int) -> subprocess.Popen:
|
||||||
|
redis_server = shutil.which("redis-server")
|
||||||
|
if redis_server is None:
|
||||||
|
raise RuntimeError("redis-server executable not found")
|
||||||
|
|
||||||
|
return subprocess.Popen(
|
||||||
|
[
|
||||||
|
redis_server,
|
||||||
|
"--bind",
|
||||||
|
host,
|
||||||
|
"--port",
|
||||||
|
str(port),
|
||||||
|
"--save",
|
||||||
|
"",
|
||||||
|
"--appendonly",
|
||||||
|
"no",
|
||||||
|
"--dir",
|
||||||
|
str(files_path),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_configs(files_path: Path, host: str, port: int) -> Path:
|
||||||
|
test_config = files_path / "test_config.yaml"
|
||||||
|
services_config = files_path / "services_config.yaml"
|
||||||
|
|
||||||
|
bec_lib_path = Path(bec_lib.__file__).resolve().parent
|
||||||
|
shutil.copyfile(bec_lib_path / "tests" / "test_config.yaml", test_config)
|
||||||
|
|
||||||
|
service_config = ServiceConfigModel(
|
||||||
|
redis={"host": host, "port": port}, file_writer={"base_path": str(files_path)}
|
||||||
|
)
|
||||||
|
services_config.write_text(service_config.model_dump_json(indent=4), encoding="utf-8")
|
||||||
|
return services_config
|
||||||
|
|
||||||
|
|
||||||
|
def _load_demo_config(services_config: Path) -> None:
|
||||||
|
bec = BECIPythonClient(ServiceConfig(services_config), RedisConnector, forced=True)
|
||||||
|
bec.start()
|
||||||
|
try:
|
||||||
|
bec.config.load_demo_config()
|
||||||
|
finally:
|
||||||
|
bec.shutdown()
|
||||||
|
bec._client._reset_singleton()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("command", nargs=argparse.REMAINDER)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command[:1] == ["--"]:
|
||||||
|
args.command = args.command[1:]
|
||||||
|
if not args.command:
|
||||||
|
raise ValueError("No command provided")
|
||||||
|
|
||||||
|
host = "127.0.0.1"
|
||||||
|
port = 6379
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="bec-benchmark-") as tmp:
|
||||||
|
files_path = Path(tmp)
|
||||||
|
services_config = _write_configs(files_path, host, port)
|
||||||
|
redis_process = _start_redis(files_path, host, port)
|
||||||
|
processes = None
|
||||||
|
service_handler = None
|
||||||
|
try:
|
||||||
|
_wait_for_redis(host, port)
|
||||||
|
|
||||||
|
from bec_server.bec_server_utils.service_handler import ServiceHandler
|
||||||
|
|
||||||
|
service_handler = ServiceHandler(
|
||||||
|
bec_path=files_path, config_path=services_config, interface="subprocess"
|
||||||
|
)
|
||||||
|
processes = service_handler.start()
|
||||||
|
_load_demo_config(services_config)
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
return subprocess.run(args.command, env=env, check=False).returncode
|
||||||
|
finally:
|
||||||
|
if service_handler is not None and processes is not None:
|
||||||
|
service_handler.stop(processes)
|
||||||
|
redis_process.terminate()
|
||||||
|
try:
|
||||||
|
redis_process.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
redis_process.kill()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
name: BW Benchmarks
|
||||||
|
|
||||||
|
on: [ workflow_call ]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
BENCHMARK_JSON: benchmark-results/current.json
|
||||||
|
BENCHMARK_BASELINE_JSON: gh-pages-benchmark-data/benchmarks/latest.json
|
||||||
|
BENCHMARK_SUMMARY: benchmark-results/summary.md
|
||||||
|
BENCHMARK_COMMAND: "bash .github/scripts/run_benchmarks.sh"
|
||||||
|
BENCHMARK_THRESHOLD_PERCENT: 20
|
||||||
|
BENCHMARK_HIGHER_IS_BETTER: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
benchmark_attempt:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
continue-on-error: true
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash -el {0}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
attempt: [ 1, 2, 3 ]
|
||||||
|
|
||||||
|
env:
|
||||||
|
BENCHMARK_JSON: benchmark-results/current-${{ matrix.attempt }}.json
|
||||||
|
BEC_CORE_BRANCH: main
|
||||||
|
OPHYD_DEVICES_BRANCH: main
|
||||||
|
PLUGIN_REPO_BRANCH: main
|
||||||
|
BENCHMARK_PYTEST_DIRS: tests/unit_tests/benchmarks
|
||||||
|
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||||
|
QT_QPA_PLATFORM: "offscreen"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout BEC Widgets
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: bec-project/bec_widgets
|
||||||
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
|
||||||
|
- name: Set up Conda
|
||||||
|
uses: conda-incubator/setup-miniconda@v3
|
||||||
|
with:
|
||||||
|
auto-update-conda: true
|
||||||
|
auto-activate-base: true
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
|
||||||
|
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
|
||||||
|
sudo apt-get -y install ttyd hyperfine redis-server
|
||||||
|
|
||||||
|
- name: Install full e2e environment
|
||||||
|
run: |
|
||||||
|
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
|
||||||
|
git clone --branch "$BEC_CORE_BRANCH" https://github.com/bec-project/bec.git
|
||||||
|
echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
|
||||||
|
git clone --branch "$OPHYD_DEVICES_BRANCH" https://github.com/bec-project/ophyd_devices.git
|
||||||
|
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||||
|
echo -e "\033[35;1m Using branch $PLUGIN_REPO_BRANCH of bec_testing_plugin \033[0;m";
|
||||||
|
git clone --branch "$PLUGIN_REPO_BRANCH" https://github.com/bec-project/bec_testing_plugin.git
|
||||||
|
cd ./bec
|
||||||
|
conda create -q -n test-environment python=3.11
|
||||||
|
conda activate test-environment
|
||||||
|
source ./bin/install_bec_dev.sh -t
|
||||||
|
cd ../
|
||||||
|
python -m pip install -e ./ophyd_devices -e .[dev,pyside6] -e ./bec_testing_plugin pytest-benchmark
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$BENCHMARK_JSON")"
|
||||||
|
python .github/scripts/run_with_bec_servers.py -- bash -lc "$BENCHMARK_COMMAND"
|
||||||
|
test -s "$BENCHMARK_JSON"
|
||||||
|
|
||||||
|
- name: Upload benchmark artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: bw-benchmark-json-${{ matrix.attempt }}
|
||||||
|
path: ${{ env.BENCHMARK_JSON }}
|
||||||
|
|
||||||
|
benchmark:
|
||||||
|
needs: [ benchmark_attempt ]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout BEC Widgets
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: bec-project/bec_widgets
|
||||||
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
|
||||||
|
- name: Download benchmark attempts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
pattern: bw-benchmark-json-*
|
||||||
|
path: benchmark-results/attempts
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Aggregate benchmark attempts
|
||||||
|
run: |
|
||||||
|
python .github/scripts/aggregate_benchmarks.py \
|
||||||
|
--input-dir benchmark-results/attempts \
|
||||||
|
--output "$BENCHMARK_JSON"
|
||||||
|
|
||||||
|
- name: Upload aggregate benchmark artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: bw-benchmark-json
|
||||||
|
path: ${{ env.BENCHMARK_JSON }}
|
||||||
|
|
||||||
|
- name: Fetch gh-pages benchmark data
|
||||||
|
run: |
|
||||||
|
if git ls-remote --exit-code --heads origin gh-pages; then
|
||||||
|
git clone --depth=1 --branch gh-pages "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" gh-pages-benchmark-data
|
||||||
|
else
|
||||||
|
mkdir -p gh-pages-benchmark-data
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Compare with latest gh-pages benchmark
|
||||||
|
id: compare
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
if [ ! -s "$BENCHMARK_BASELINE_JSON" ]; then
|
||||||
|
mkdir -p "$(dirname "$BENCHMARK_SUMMARY")"
|
||||||
|
{
|
||||||
|
echo "<!-- bw-benchmark-comment -->"
|
||||||
|
echo "## Benchmark comparison"
|
||||||
|
echo
|
||||||
|
echo "No benchmark baseline was found on gh-pages."
|
||||||
|
} > "$BENCHMARK_SUMMARY"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
args=(
|
||||||
|
--baseline "$BENCHMARK_BASELINE_JSON"
|
||||||
|
--current "$BENCHMARK_JSON"
|
||||||
|
--summary "$BENCHMARK_SUMMARY"
|
||||||
|
--threshold-percent "$BENCHMARK_THRESHOLD_PERCENT"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "$BENCHMARK_HIGHER_IS_BETTER" = "true" ]; then
|
||||||
|
args+=(--higher-is-better)
|
||||||
|
fi
|
||||||
|
|
||||||
|
set +e
|
||||||
|
python .github/scripts/compare_benchmarks.py "${args[@]}"
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ ! -s "$BENCHMARK_SUMMARY" ]; then
|
||||||
|
mkdir -p "$(dirname "$BENCHMARK_SUMMARY")"
|
||||||
|
{
|
||||||
|
echo "<!-- bw-benchmark-comment -->"
|
||||||
|
echo "## Benchmark comparison"
|
||||||
|
echo
|
||||||
|
echo "Benchmark comparison failed before writing a summary."
|
||||||
|
} > "$BENCHMARK_SUMMARY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$status"
|
||||||
|
|
||||||
|
- name: Find existing benchmark PR comment
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
id: fc
|
||||||
|
uses: peter-evans/find-comment@v3
|
||||||
|
with:
|
||||||
|
issue-number: ${{ github.event.pull_request.number }}
|
||||||
|
comment-author: github-actions[bot]
|
||||||
|
body-includes: "<!-- bw-benchmark-comment -->"
|
||||||
|
|
||||||
|
- name: Create or update benchmark PR comment
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
uses: peter-evans/create-or-update-comment@v5
|
||||||
|
with:
|
||||||
|
issue-number: ${{ github.event.pull_request.number }}
|
||||||
|
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||||
|
body-path: ${{ env.BENCHMARK_SUMMARY }}
|
||||||
|
edit-mode: replace
|
||||||
|
|
||||||
|
- name: Fail on benchmark regression
|
||||||
|
if: github.event_name == 'pull_request' && steps.compare.outcome == 'failure'
|
||||||
|
run: exit 1
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: [ benchmark ]
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout BEC Widgets
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: bec-project/bec_widgets
|
||||||
|
ref: ${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Download aggregate benchmark artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: bw-benchmark-json
|
||||||
|
path: benchmark-results
|
||||||
|
|
||||||
|
- name: Verify aggregate benchmark artifact
|
||||||
|
run: test -s "$BENCHMARK_JSON"
|
||||||
|
|
||||||
|
- name: Prepare gh-pages for publishing
|
||||||
|
run: |
|
||||||
|
# Clean up any existing worktree/directory
|
||||||
|
if [ -d gh-pages-benchmark-data ]; then
|
||||||
|
git worktree remove gh-pages-benchmark-data --force || rm -rf gh-pages-benchmark-data
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git ls-remote --exit-code --heads origin gh-pages; then
|
||||||
|
git fetch --depth=1 origin gh-pages
|
||||||
|
git worktree add gh-pages-benchmark-data FETCH_HEAD
|
||||||
|
else
|
||||||
|
git worktree add --detach gh-pages-benchmark-data
|
||||||
|
git -C gh-pages-benchmark-data checkout --orphan gh-pages
|
||||||
|
git -C gh-pages-benchmark-data rm -rf .
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Publish benchmark data to gh-pages
|
||||||
|
working-directory: gh-pages-benchmark-data
|
||||||
|
run: |
|
||||||
|
mkdir -p benchmarks/history
|
||||||
|
cp "../$BENCHMARK_JSON" benchmarks/latest.json
|
||||||
|
cp "../$BENCHMARK_JSON" "benchmarks/history/${GITHUB_SHA}.json"
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add benchmarks/latest.json "benchmarks/history/${GITHUB_SHA}.json"
|
||||||
|
git commit -m "Update BW benchmark data for ${GITHUB_SHA}" || exit 0
|
||||||
|
git push origin HEAD:gh-pages
|
||||||
@@ -5,15 +5,15 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
BEC_WIDGETS_BRANCH:
|
BEC_WIDGETS_BRANCH:
|
||||||
description: 'Branch of BEC Widgets to install'
|
description: "Branch of BEC Widgets to install"
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
BEC_CORE_BRANCH:
|
BEC_CORE_BRANCH:
|
||||||
description: 'Branch of BEC Core to install'
|
description: "Branch of BEC Core to install"
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
OPHYD_DEVICES_BRANCH:
|
OPHYD_DEVICES_BRANCH:
|
||||||
description: 'Branch of Ophyd Devices to install'
|
description: "Branch of Ophyd Devices to install"
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ concurrency:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check_pr_status:
|
check_pr_status:
|
||||||
@@ -33,6 +34,15 @@ jobs:
|
|||||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||||
uses: ./.github/workflows/formatter.yml
|
uses: ./.github/workflows/formatter.yml
|
||||||
|
|
||||||
|
benchmark:
|
||||||
|
needs: [check_pr_status]
|
||||||
|
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
uses: ./.github/workflows/benchmark.yml
|
||||||
|
|
||||||
unit-test:
|
unit-test:
|
||||||
needs: [check_pr_status, formatter]
|
needs: [check_pr_status, formatter]
|
||||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||||
|
|||||||
@@ -3,23 +3,23 @@ on:
|
|||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
pr_number:
|
pr_number:
|
||||||
description: 'Pull request number'
|
description: "Pull request number"
|
||||||
required: false
|
required: false
|
||||||
type: number
|
type: number
|
||||||
BEC_CORE_BRANCH:
|
BEC_CORE_BRANCH:
|
||||||
description: 'Branch of BEC Core to install'
|
description: "Branch of BEC Core to install"
|
||||||
required: false
|
required: false
|
||||||
default: 'main'
|
default: "main"
|
||||||
type: string
|
type: string
|
||||||
OPHYD_DEVICES_BRANCH:
|
OPHYD_DEVICES_BRANCH:
|
||||||
description: 'Branch of Ophyd Devices to install'
|
description: "Branch of Ophyd Devices to install"
|
||||||
required: false
|
required: false
|
||||||
default: 'main'
|
default: "main"
|
||||||
type: string
|
type: string
|
||||||
BEC_WIDGETS_BRANCH:
|
BEC_WIDGETS_BRANCH:
|
||||||
description: 'Branch of BEC Widgets to install'
|
description: "Branch of BEC Widgets to install"
|
||||||
required: false
|
required: false
|
||||||
default: 'main'
|
default: "main"
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -30,15 +30,14 @@ jobs:
|
|||||||
python-version: ["3.11", "3.12", "3.13"]
|
python-version: ["3.11", "3.12", "3.13"]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets
|
BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets
|
||||||
BEC_CORE_BRANCH: main # Set the branch you want for bec
|
BEC_CORE_BRANCH: main # Set the branch you want for bec
|
||||||
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
|
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
|
||||||
PROJECT_PATH: ${{ github.repository }}
|
PROJECT_PATH: ${{ github.repository }}
|
||||||
QTWEBENGINE_DISABLE_SANDBOX: 1
|
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||||
QT_QPA_PLATFORM: "offscreen"
|
QT_QPA_PLATFORM: "offscreen"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
- name: Checkout BEC Widgets
|
- name: Checkout BEC Widgets
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -56,4 +55,4 @@ jobs:
|
|||||||
- name: Run Pytest
|
- name: Run Pytest
|
||||||
run: |
|
run: |
|
||||||
pip install pytest pytest-random-order
|
pip install pytest pytest-random-order
|
||||||
pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
|
pytest -v --junitxml=report.xml --random-order --ignore=tests/unit_tests/benchmarks ./tests/unit_tests
|
||||||
|
|||||||
@@ -3,30 +3,28 @@ on:
|
|||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
pr_number:
|
pr_number:
|
||||||
description: 'Pull request number'
|
description: "Pull request number"
|
||||||
required: false
|
required: false
|
||||||
type: number
|
type: number
|
||||||
BEC_CORE_BRANCH:
|
BEC_CORE_BRANCH:
|
||||||
description: 'Branch of BEC Core to install'
|
description: "Branch of BEC Core to install"
|
||||||
required: false
|
required: false
|
||||||
default: 'main'
|
default: "main"
|
||||||
type: string
|
type: string
|
||||||
OPHYD_DEVICES_BRANCH:
|
OPHYD_DEVICES_BRANCH:
|
||||||
description: 'Branch of Ophyd Devices to install'
|
description: "Branch of Ophyd Devices to install"
|
||||||
required: false
|
required: false
|
||||||
default: 'main'
|
default: "main"
|
||||||
type: string
|
type: string
|
||||||
BEC_WIDGETS_BRANCH:
|
BEC_WIDGETS_BRANCH:
|
||||||
description: 'Branch of BEC Widgets to install'
|
description: "Branch of BEC Widgets to install"
|
||||||
required: false
|
required: false
|
||||||
default: 'main'
|
default: "main"
|
||||||
type: string
|
type: string
|
||||||
secrets:
|
secrets:
|
||||||
CODECOV_TOKEN:
|
CODECOV_TOKEN:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
@@ -55,7 +53,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Pytest with Coverage
|
- name: Run Pytest with Coverage
|
||||||
id: coverage
|
id: coverage
|
||||||
run: pytest --random-order --cov=bec_widgets --cov-config=pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail tests/unit_tests/
|
run: pytest --random-order --cov=bec_widgets --cov-config=pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail --ignore=tests/unit_tests/benchmarks tests/unit_tests/
|
||||||
|
|
||||||
- name: Upload test artifacts
|
- name: Upload test artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
@@ -178,3 +178,5 @@ cython_debug/
|
|||||||
# 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
|
||||||
|
|||||||
+292
@@ -1,6 +1,298 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
|
||||||
|
## v3.8.0 (2026-05-01)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **dock_area**: Change to show_dialo=False for CLI profile baseline restore
|
||||||
|
([`0b1f0b4`](https://github.com/bec-project/bec_widgets/commit/0b1f0b4c262ff31469b7114b9f00bf0a7b85e8f2))
|
||||||
|
|
||||||
|
- **dock_area**: Cli call load_profile has restore_baseline kwarg
|
||||||
|
([`cc82597`](https://github.com/bec-project/bec_widgets/commit/cc825972c202cd9ded32f8b2d1ce5f822c2ebdba))
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **dock_area**: Add CLI restore current profile from baseline with optional confirmation dialog
|
||||||
|
([`17865a2`](https://github.com/bec-project/bec_widgets/commit/17865a2c338a4a1f944659dde4ec05c25a8dd963))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.7.3 (2026-05-01)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **dock_area**: Profile names changed, default->baseline, user->runtime
|
||||||
|
([`dd32caf`](https://github.com/bec-project/bec_widgets/commit/dd32caf6e815fd1922b6aae84d00decad9dbf869))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- **dock_area**: Remove low-value tests
|
||||||
|
([`717d74b`](https://github.com/bec-project/bec_widgets/commit/717d74b19e8c6960209190c47ba32732ffaa0094))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.7.2 (2026-04-29)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **dock-area**: Avoid switching profile when saving new profile
|
||||||
|
([`73b44cf`](https://github.com/bec-project/bec_widgets/commit/73b44cffb219347cacb609f3b93068eda6701b42))
|
||||||
|
|
||||||
|
- **workspace-actions**: Use try/finally and restore previous blocked state in refresh_profiles
|
||||||
|
([`30ef255`](https://github.com/bec-project/bec_widgets/commit/30ef25533af9df5a9bd9e69808dc49fdf22f4318))
|
||||||
|
|
||||||
|
Agent-Logs-Url:
|
||||||
|
https://github.com/bec-project/bec_widgets/sessions/004cb4bc-5015-485e-a803-1e63876b7024
|
||||||
|
|
||||||
|
Co-authored-by: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com>
|
||||||
|
|
||||||
|
### Build System
|
||||||
|
|
||||||
|
- Add pytest-benchmark dependency
|
||||||
|
([`551d38d`](https://github.com/bec-project/bec_widgets/commit/551d38d90111361e64bfcf10a1409be71dd298bc))
|
||||||
|
|
||||||
|
### Chores
|
||||||
|
|
||||||
|
- Update header comments in script files to indicate AI generation
|
||||||
|
([`3f1aa80`](https://github.com/bec-project/bec_widgets/commit/3f1aa80756368454c3631cb6cf9d29db28af177a))
|
||||||
|
|
||||||
|
### Continuous Integration
|
||||||
|
|
||||||
|
- Add benchmark workflow
|
||||||
|
([`999b7a2`](https://github.com/bec-project/bec_widgets/commit/999b7a2321f2f222c04b056a2db4280f66de9c48))
|
||||||
|
|
||||||
|
- Fix benchmark upload
|
||||||
|
([`19b5c8f`](https://github.com/bec-project/bec_widgets/commit/19b5c8f724dbdb7421957c290f9213bf072392df))
|
||||||
|
|
||||||
|
- Increase threshold to 20 percent
|
||||||
|
([`409c9e5`](https://github.com/bec-project/bec_widgets/commit/409c9e5bfacdfc003f1bc8c9944f01798bd3818e))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Fix assertions after updating ophyd devices templates
|
||||||
|
([`a614d66`](https://github.com/bec-project/bec_widgets/commit/a614d662d6da716a49afb4ed3a3903f108210386))
|
||||||
|
|
||||||
|
Co-authored-by: Copilot <copilot@github.com>
|
||||||
|
|
||||||
|
- Remove references to "scan_motors" in tests
|
||||||
|
([`5056ef8`](https://github.com/bec-project/bec_widgets/commit/5056ef8946d03a20e802709d3fe81c84c195fe41))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.7.1 (2026-04-21)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **heatmap**: Fix access to status from metadata
|
||||||
|
([`55694ff`](https://github.com/bec-project/bec_widgets/commit/55694ff2b96581e03c63c8b8e068e2db79bcf780))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Fix exit status and status access in tests
|
||||||
|
([`91afc77`](https://github.com/bec-project/bec_widgets/commit/91afc775d59b4ba31bed3585847a67c301acf9b0))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.7.0 (2026-04-21)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Move companion app to applications
|
||||||
|
([`0cf84cd`](https://github.com/bec-project/bec_widgets/commit/0cf84cd1d839ac4a39ffb5fb9ba57d432e04348a))
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
- Cleanup of imports
|
||||||
|
([`3e77f54`](https://github.com/bec-project/bec_widgets/commit/3e77f540345f56b9f184a332fcdd50d4d4c8c621))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.6.0 (2026-04-21)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Change resize mode to interactive
|
||||||
|
([`a5db2dc`](https://github.com/bec-project/bec_widgets/commit/a5db2dc340f3386e68b300fd4528a44f87cbbf97))
|
||||||
|
|
||||||
|
- Small usability changes
|
||||||
|
([`5a497c3`](https://github.com/bec-project/bec_widgets/commit/5a497c3598c2d8f27916d91d53c646d5d6d3a4a7))
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add button/slot to pause/unpause logs
|
||||||
|
([`23e3644`](https://github.com/bec-project/bec_widgets/commit/23e3644619de958bcfdb8a0b2ee1f7c2ce05b235))
|
||||||
|
|
||||||
|
- Add logpanel to menu
|
||||||
|
([`2e8f43f`](https://github.com/bec-project/bec_widgets/commit/2e8f43fcac581cd1c227308198565d142a1bf276))
|
||||||
|
|
||||||
|
- Migrate logpanel to table model/view
|
||||||
|
([`09bb112`](https://github.com/bec-project/bec_widgets/commit/09bb1121d83bac1f6e4827daa476fbe7cd5b3a80))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.5.1 (2026-04-20)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Don't assume attr exists if we timed out waiting for it
|
||||||
|
([`f7a1ee4`](https://github.com/bec-project/bec_widgets/commit/f7a1ee49a42c58ba315c8957b45a80d862ffe745))
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
- Don't import real widgets in client
|
||||||
|
([`8e51c1a`](https://github.com/bec-project/bec_widgets/commit/8e51c1adb6a7658c54846794cf97b774cbac2193))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.5.0 (2026-04-14)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Connect signals the correct way around
|
||||||
|
([`f562c61`](https://github.com/bec-project/bec_widgets/commit/f562c61e3cec3387f6821bad74403beeb3436355))
|
||||||
|
|
||||||
|
- Create new bec shell if deleted
|
||||||
|
([`1754e75`](https://github.com/bec-project/bec_widgets/commit/1754e759f0c59f2f4063f661bacd334127326947))
|
||||||
|
|
||||||
|
- Formatting in plugin template
|
||||||
|
([`fa2ef83`](https://github.com/bec-project/bec_widgets/commit/fa2ef83bb9dfeeb4c5fc7cd77168c16101c32693))
|
||||||
|
|
||||||
|
- **bec_console**: Persistent bec session
|
||||||
|
([`9b0ec9d`](https://github.com/bec-project/bec_widgets/commit/9b0ec9dd79ad1adc5d211dd703db7441da965f34))
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add qtermwidget plugin and replace web term
|
||||||
|
([`02cb393`](https://github.com/bec-project/bec_widgets/commit/02cb393bb086165dc64917b633d5570d02e1a2a9))
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
- Code cleanup
|
||||||
|
([`bda5d38`](https://github.com/bec-project/bec_widgets/commit/bda5d389651bb2b13734cd31159679e85b1bd583))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.4.4 (2026-04-14)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Check duplicate stream sub
|
||||||
|
([`c7de320`](https://github.com/bec-project/bec_widgets/commit/c7de320ca564264a31b84931f553170f25659685))
|
||||||
|
|
||||||
|
- Check for duplicate subscriptions in GUIClient
|
||||||
|
([`37747ba`](https://github.com/bec-project/bec_widgets/commit/37747babda407040333c6bd04646be9a49e0ee81))
|
||||||
|
|
||||||
|
- Make gui client registry callback non static
|
||||||
|
([`32f5d48`](https://github.com/bec-project/bec_widgets/commit/32f5d486d3fc8d41df2668c58932ae982819b285))
|
||||||
|
|
||||||
|
- Remove staticmethod subscription
|
||||||
|
([`0ff1fdc`](https://github.com/bec-project/bec_widgets/commit/0ff1fdc81578eec3ffc5d4030fca7b357a0b4c2f))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.4.3 (2026-04-13)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Set OPHYD_CONTROL_LAYER to dummy for tests
|
||||||
|
([`5e84d3b`](https://github.com/bec-project/bec_widgets/commit/5e84d3bec608ae9f2ee6dae67db2e3e1387b1f59))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.4.2 (2026-04-01)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Allow admin user to pass deployment group check
|
||||||
|
([`e6c8cd0`](https://github.com/bec-project/bec_widgets/commit/e6c8cd0b1a1162302071c93a2ac51880b3cf1b7d))
|
||||||
|
|
||||||
|
- **bec-atlas-admin-view**: Fix atlas_url to bec-atlas-prod.psi.ch
|
||||||
|
([`242f893`](https://github.com/bec-project/bec_widgets/commit/242f8933b246802f5f3a5b9df7de07901f151c82))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Add tests for admin access
|
||||||
|
([`2dab16b`](https://github.com/bec-project/bec_widgets/commit/2dab16b68415806f3f291657f394bb2d8654229d))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.4.1 (2026-04-01)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **hover_widget**: Make it fancy + mouse tracking
|
||||||
|
([`e25b660`](https://github.com/bec-project/bec_widgets/commit/e25b6604d195804bbd6ea6aac395d44dc00d6155))
|
||||||
|
|
||||||
|
- **ring**: Changed inheritance to BECWidget and added cleanup
|
||||||
|
([`2f75aae`](https://github.com/bec-project/bec_widgets/commit/2f75aaea16a178e180e68d702cd1bdf85a768bcf))
|
||||||
|
|
||||||
|
- **ring**: Hook update hover to update method
|
||||||
|
([`90ecd8e`](https://github.com/bec-project/bec_widgets/commit/90ecd8ea87faf06c3f545e3f9241f403b733d7eb))
|
||||||
|
|
||||||
|
- **ring**: Minor general fixes
|
||||||
|
([`6775509`](https://github.com/bec-project/bec_widgets/commit/677550931b28fbf35fd55880bf6e001f7351b99b))
|
||||||
|
|
||||||
|
- **ring_progress_bar**: Added hover mouse effect
|
||||||
|
([`96b5179`](https://github.com/bec-project/bec_widgets/commit/96b5179658c41fb39df7a40f4d96e82092605791))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- **ring_progress_bar**: Add unit tests for hover behavior
|
||||||
|
([`6e5f6e7`](https://github.com/bec-project/bec_widgets/commit/6e5f6e7fbb6f9680f6d026e105e6840d24c6591c))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.4.0 (2026-03-26)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **lmfit_dialog**: Compact layout size policy for better alignment panel UX
|
||||||
|
([`31389a3`](https://github.com/bec-project/bec_widgets/commit/31389a3dd0c7b1c671acdf49ae50b08455f466a7))
|
||||||
|
|
||||||
|
- **waveform**: Alignment panel indicators request autoscale if updated
|
||||||
|
([`a292375`](https://github.com/bec-project/bec_widgets/commit/a2923752c27ad7b9749db3d309fe288747b85acb))
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **waveform**: 1d alignment mode panel
|
||||||
|
([`a486c52`](https://github.com/bec-project/bec_widgets/commit/a486c52058b4edbea00ad7bb018f1fa2822fb9c6))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.3.4 (2026-03-24)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **lmfit_dialog**: Dialog compact adjustment and cleanup of stale methods
|
||||||
|
([`f67b60a`](https://github.com/bec-project/bec_widgets/commit/f67b60ac98cd87ed8391fee8545eb8064a068e67))
|
||||||
|
|
||||||
|
- **lmfit_dialog**: Fix cpp object deleted
|
||||||
|
([`5ec59d5`](https://github.com/bec-project/bec_widgets/commit/5ec59d5dbb75e3a9deb488b0affaf8cb704242b9))
|
||||||
|
|
||||||
|
- **lmfit_dialog**: Fix fit_curve_id type annotation and remove_dap_data selection behavior
|
||||||
|
([`05c38d9`](https://github.com/bec-project/bec_widgets/commit/05c38d9b82cc6dfaec8f5abf8e0ececa5d001524))
|
||||||
|
|
||||||
|
Co-authored-by: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com>
|
||||||
|
|
||||||
|
Agent-Logs-Url:
|
||||||
|
https://github.com/bec-project/bec_widgets/sessions/97395c0e-0271-4cdf-b39f-f3117d21bfa3
|
||||||
|
|
||||||
|
|
||||||
|
## v3.3.3 (2026-03-23)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **positioner_box**: Remove CompactPopupWidget inheritance
|
||||||
|
([`da400d2`](https://github.com/bec-project/bec_widgets/commit/da400d20b672236241ce3a4480481ac6a5df1b2e))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.3.2 (2026-03-22)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Typos
|
||||||
|
([`3d29a67`](https://github.com/bec-project/bec_widgets/commit/3d29a67c0b2175f2f29b8e5a7befce55f3d28fd3))
|
||||||
|
|
||||||
|
|
||||||
|
## v3.3.1 (2026-03-20)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **dap_combobox**: Added safeguard for no DAP models
|
||||||
|
([`79af15a`](https://github.com/bec-project/bec_widgets/commit/79af15a88b993cd5b6bf730796f995f20cf6f188))
|
||||||
|
|
||||||
|
- **dap_combobox**: Rewritten as proper combobox
|
||||||
|
([`90222f3`](https://github.com/bec-project/bec_widgets/commit/90222f30821f822eb24b0179401d4e43050e0156))
|
||||||
|
|
||||||
|
|
||||||
## v3.3.0 (2026-03-20)
|
## v3.3.0 (2026-03-20)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
+12
-18
@@ -1,19 +1,13 @@
|
|||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import bec_widgets.widgets.containers.qt_ads as QtAds
|
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
|
||||||
|
|
||||||
if sys.platform.startswith("linux"):
|
|
||||||
qt_platform = os.environ.get("QT_QPA_PLATFORM", "")
|
|
||||||
if qt_platform != "offscreen":
|
|
||||||
os.environ["QT_QPA_PLATFORM"] = "xcb"
|
|
||||||
|
|
||||||
# Default QtAds configuration
|
|
||||||
QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True)
|
|
||||||
QtAds.CDockManager.setConfigFlag(
|
|
||||||
QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = ["BECWidget", "SafeSlot", "SafeProperty"]
|
__all__ = ["BECWidget", "SafeSlot", "SafeProperty"]
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name):
|
||||||
|
if name == "BECWidget":
|
||||||
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
|
|
||||||
|
return BECWidget
|
||||||
|
if name in {"SafeSlot", "SafeProperty"}:
|
||||||
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
|
|
||||||
|
return {"SafeSlot": SafeSlot, "SafeProperty": SafeProperty}[name]
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import bec_widgets.widgets.containers.qt_ads as QtAds
|
||||||
|
|
||||||
|
if sys.platform.startswith("linux"):
|
||||||
|
qt_platform = os.environ.get("QT_QPA_PLATFORM", "")
|
||||||
|
if qt_platform != "offscreen":
|
||||||
|
os.environ["QT_QPA_PLATFORM"] = "xcb"
|
||||||
|
|
||||||
|
# Default QtAds configuration
|
||||||
|
QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True)
|
||||||
|
QtAds.CDockManager.setConfigFlag(
|
||||||
|
QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True
|
||||||
|
)
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ from qtpy.QtWidgets import QApplication
|
|||||||
|
|
||||||
import bec_widgets
|
import bec_widgets
|
||||||
from bec_widgets.applications.launch_window import LaunchWindow
|
from bec_widgets.applications.launch_window import LaunchWindow
|
||||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
|
||||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||||
|
from bec_widgets.utils.rpc_register import RPCRegister
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
|
|
||||||
@@ -20,13 +20,13 @@ from qtpy.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
import bec_widgets
|
import bec_widgets
|
||||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
|
||||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
||||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||||
from bec_widgets.utils.error_popups import SafeSlot
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
from bec_widgets.utils.name_utils import pascal_to_snake
|
from bec_widgets.utils.name_utils import pascal_to_snake
|
||||||
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
|
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
|
||||||
from bec_widgets.utils.round_frame import RoundedFrame
|
from bec_widgets.utils.round_frame import RoundedFrame
|
||||||
|
from bec_widgets.utils.rpc_register import RPCRegister
|
||||||
from bec_widgets.utils.screen_utils import apply_window_geometry, centered_geometry_for_app
|
from bec_widgets.utils.screen_utils import apply_window_geometry, centered_geometry_for_app
|
||||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||||
from bec_widgets.utils.ui_loader import UILoader
|
from bec_widgets.utils.ui_loader import UILoader
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ class BECMainApp(BECMainWindow):
|
|||||||
developer_view_step = self.guided_tour.register_widget(
|
developer_view_step = self.guided_tour.register_widget(
|
||||||
widget=sidebar_developer_view,
|
widget=sidebar_developer_view,
|
||||||
title="Developer View",
|
title="Developer View",
|
||||||
text="Click here to access the Developer view to write scripts and makros.",
|
text="Click here to access the Developer view to write scripts and macros.",
|
||||||
)
|
)
|
||||||
tour_steps.append(developer_view_step)
|
tour_steps.append(developer_view_step)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ class DeveloperWidget(DockAreaWidget):
|
|||||||
|
|
||||||
self.console = BECShell(self, rpc_exposed=False)
|
self.console = BECShell(self, rpc_exposed=False)
|
||||||
self.console.setObjectName("BEC Shell")
|
self.console.setObjectName("BEC Shell")
|
||||||
self.terminal = WebConsole(self, rpc_exposed=False)
|
self.terminal = BecConsole(self, rpc_exposed=False)
|
||||||
self.terminal.setObjectName("Terminal")
|
self.terminal.setObjectName("Terminal")
|
||||||
self.monaco = MonacoDock(self, rpc_exposed=False, rpc_passthrough_children=False)
|
self.monaco = MonacoDock(self, rpc_exposed=False, rpc_passthrough_children=False)
|
||||||
self.monaco.setObjectName("MonacoEditor")
|
self.monaco.setObjectName("MonacoEditor")
|
||||||
@@ -410,23 +410,3 @@ 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_())
|
|
||||||
|
|||||||
@@ -169,7 +169,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 seperate config helper instance to avoid conflicts with
|
# NOTE: We need here a separate 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 +607,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, connnection_status in validation_results.values():
|
for config, config_status, connection_status in validation_results.values():
|
||||||
if connnection_status == ConnectionStatus.CONNECTED.value:
|
if connection_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, ""
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
from bec_widgets.cli.rpc import rpc_base
|
||||||
|
|||||||
+200
-69
@@ -13,7 +13,7 @@ from typing import Literal, Optional
|
|||||||
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_all_plugin_widgets, get_plugin_client_module
|
from bec_widgets.utils.bec_plugin_helper import get_plugin_client_module
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ logger = bec_logger.logger
|
|||||||
|
|
||||||
|
|
||||||
class _WidgetsEnumType(str, enum.Enum):
|
class _WidgetsEnumType(str, enum.Enum):
|
||||||
"""Enum for the available widgets, to be generated programatically"""
|
"""Enum for the available widgets, to be generated programmatically"""
|
||||||
|
|
||||||
...
|
...
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ _Widgets = {
|
|||||||
"BECQueue": "BECQueue",
|
"BECQueue": "BECQueue",
|
||||||
"BECShell": "BECShell",
|
"BECShell": "BECShell",
|
||||||
"BECStatusBox": "BECStatusBox",
|
"BECStatusBox": "BECStatusBox",
|
||||||
|
"BecConsole": "BecConsole",
|
||||||
"DapComboBox": "DapComboBox",
|
"DapComboBox": "DapComboBox",
|
||||||
"DeviceBrowser": "DeviceBrowser",
|
"DeviceBrowser": "DeviceBrowser",
|
||||||
"Heatmap": "Heatmap",
|
"Heatmap": "Heatmap",
|
||||||
@@ -56,35 +57,24 @@ _Widgets = {
|
|||||||
"SignalLabel": "SignalLabel",
|
"SignalLabel": "SignalLabel",
|
||||||
"TextBox": "TextBox",
|
"TextBox": "TextBox",
|
||||||
"Waveform": "Waveform",
|
"Waveform": "Waveform",
|
||||||
"WebConsole": "WebConsole",
|
|
||||||
"WebsiteWidget": "WebsiteWidget",
|
"WebsiteWidget": "WebsiteWidget",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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} from {conflicting_file} conflicts with a built-in class!"
|
f"Plugin widget {plugin_name} in {plugin_class._IMPORT_MODULE} conflicts with a built-in class!"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if plugin_name not in _overlap:
|
else:
|
||||||
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))}")
|
||||||
|
|
||||||
@@ -92,6 +82,8 @@ except ImportError as e:
|
|||||||
class AdminView(RPCBase):
|
class AdminView(RPCBase):
|
||||||
"""A view for administrators to change the current active experiment, manage messaging"""
|
"""A view for administrators to change the current active experiment, manage messaging"""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.applications.views.admin_view.admin_view"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def activate(self) -> "None":
|
def activate(self) -> "None":
|
||||||
"""
|
"""
|
||||||
@@ -100,6 +92,8 @@ class AdminView(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class AutoUpdates(RPCBase):
|
class AutoUpdates(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.containers.auto_update.auto_updates"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def enabled(self) -> "bool":
|
def enabled(self) -> "bool":
|
||||||
@@ -136,6 +130,8 @@ class AutoUpdates(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class AvailableDeviceResources(RPCBase):
|
class AvailableDeviceResources(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -156,6 +152,8 @@ class AvailableDeviceResources(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class BECDockArea(RPCBase):
|
class BECDockArea(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.containers.dock_area.dock_area"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def new(
|
def new(
|
||||||
self,
|
self,
|
||||||
@@ -342,10 +340,10 @@ class BECDockArea(RPCBase):
|
|||||||
Save the current workspace profile.
|
Save the current workspace profile.
|
||||||
|
|
||||||
On first save of a given name:
|
On first save of a given name:
|
||||||
- writes a default copy to states/default/<name>.ini with tag=default and created_at
|
- writes a baseline copy to profiles/baseline/<name>.ini with created_at
|
||||||
- writes a user copy to states/user/<name>.ini with tag=user and created_at
|
- writes a runtime copy to profiles/runtime/<name>.ini with created_at
|
||||||
On subsequent saves of user-owned profiles:
|
On subsequent saves:
|
||||||
- updates both the default and user copies so restore uses the latest snapshot.
|
- updates both the baseline and runtime copies so restore uses the latest snapshot.
|
||||||
Read-only bundled profiles cannot be overwritten.
|
Read-only bundled profiles cannot be overwritten.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -360,15 +358,31 @@ class BECDockArea(RPCBase):
|
|||||||
|
|
||||||
@rpc_timeout(None)
|
@rpc_timeout(None)
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def load_profile(self, name: "str | None" = None):
|
def load_profile(self, name: "str | None" = None, restore_baseline: "bool" = False):
|
||||||
"""
|
"""
|
||||||
Load a workspace profile.
|
Load a workspace profile.
|
||||||
|
|
||||||
Before switching, persist the current profile to the user copy.
|
Before switching, persist the current profile to the runtime copy.
|
||||||
Prefer loading the user copy; fall back to the default copy.
|
Prefer loading the runtime copy; fall back to the baseline copy. When
|
||||||
|
``restore_baseline`` is True, first overwrite the runtime copy with the
|
||||||
|
baseline profile and then load it.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str | None): The name of the profile to load. If None, prompts the user.
|
name (str | None): The name of the profile to load. If None, prompts the user.
|
||||||
|
restore_baseline (bool): If True, restore the runtime copy from the
|
||||||
|
baseline before loading. Defaults to False.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@rpc_timeout(None)
|
||||||
|
@rpc_call
|
||||||
|
def restore_baseline_profile(self, name: "str | None" = None, show_dialog: "bool" = False):
|
||||||
|
"""
|
||||||
|
Overwrite the runtime copy of *name* with the baseline.
|
||||||
|
If *name* is None, target the currently active profile.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name (str | None): The name of the profile to restore. If None, uses the current profile.
|
||||||
|
show_dialog (bool): If True, ask for confirmation before restoring.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
@@ -391,6 +405,8 @@ class BECDockArea(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class BECMainWindow(RPCBase):
|
class BECMainWindow(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.containers.main_window.main_window"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -413,6 +429,8 @@ class BECMainWindow(RPCBase):
|
|||||||
class BECProgressBar(RPCBase):
|
class BECProgressBar(RPCBase):
|
||||||
"""A custom progress bar with smooth transitions. The displayed text can be customized using a template."""
|
"""A custom progress bar with smooth transitions. The displayed text can be customized using a template."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.progress.bec_progressbar.bec_progressbar"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_value(self, value):
|
def set_value(self, value):
|
||||||
"""
|
"""
|
||||||
@@ -486,6 +504,8 @@ class BECProgressBar(RPCBase):
|
|||||||
class BECQueue(RPCBase):
|
class BECQueue(RPCBase):
|
||||||
"""Widget to display the BEC queue."""
|
"""Widget to display the BEC queue."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.services.bec_queue.bec_queue"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -506,7 +526,9 @@ class BECQueue(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class BECShell(RPCBase):
|
class BECShell(RPCBase):
|
||||||
"""A WebConsole pre-configured to run the BEC shell."""
|
"""A BecConsole pre-configured to run the BEC shell."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.editors.bec_console.bec_console"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
@@ -530,6 +552,8 @@ class BECShell(RPCBase):
|
|||||||
class BECStatusBox(RPCBase):
|
class BECStatusBox(RPCBase):
|
||||||
"""An autonomous widget to display the status of BEC services."""
|
"""An autonomous widget to display the status of BEC services."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.services.bec_status_box.bec_status_box"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def get_server_state(self) -> "str":
|
def get_server_state(self) -> "str":
|
||||||
"""
|
"""
|
||||||
@@ -565,6 +589,8 @@ class BECStatusBox(RPCBase):
|
|||||||
class BaseROI(RPCBase):
|
class BaseROI(RPCBase):
|
||||||
"""Base class for all Region of Interest (ROI) implementations."""
|
"""Base class for all Region of Interest (ROI) implementations."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def label(self) -> "str":
|
def label(self) -> "str":
|
||||||
@@ -691,9 +717,35 @@ class BaseROI(RPCBase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class BecConsole(RPCBase):
|
||||||
|
"""A console widget with access to a shared registry of terminals, such that instances can be moved around."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.editors.bec_console.bec_console"
|
||||||
|
|
||||||
|
@rpc_call
|
||||||
|
def remove(self):
|
||||||
|
"""
|
||||||
|
Cleanup the BECConnector
|
||||||
|
"""
|
||||||
|
|
||||||
|
@rpc_call
|
||||||
|
def attach(self):
|
||||||
|
"""
|
||||||
|
None
|
||||||
|
"""
|
||||||
|
|
||||||
|
@rpc_call
|
||||||
|
def detach(self):
|
||||||
|
"""
|
||||||
|
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class CircularROI(RPCBase):
|
class CircularROI(RPCBase):
|
||||||
"""Circular Region of Interest with center/diameter tracking and auto-labeling."""
|
"""Circular Region of Interest with center/diameter tracking and auto-labeling."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def label(self) -> "str":
|
def label(self) -> "str":
|
||||||
@@ -821,6 +873,8 @@ class CircularROI(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class Curve(RPCBase):
|
class Curve(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.plots.waveform.curve"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -985,7 +1039,9 @@ class Curve(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class DapComboBox(RPCBase):
|
class DapComboBox(RPCBase):
|
||||||
"""The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC."""
|
"""Editable combobox listing the available DAP models."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.dap.dap_combo_box.dap_combo_box"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def select_y_axis(self, y_axis: str):
|
def select_y_axis(self, y_axis: str):
|
||||||
@@ -1011,13 +1067,15 @@ class DapComboBox(RPCBase):
|
|||||||
Slot to update the fit model.
|
Slot to update the fit model.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
default_device(str): Default device name.
|
fit_name(str): Fit model name.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class DeveloperView(RPCBase):
|
class DeveloperView(RPCBase):
|
||||||
"""A view for users to write scripts and macros and execute them within the application."""
|
"""A view for users to write scripts and macros and execute them within the application."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.applications.views.developer_view.developer_view"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def activate(self) -> "None":
|
def activate(self) -> "None":
|
||||||
"""
|
"""
|
||||||
@@ -1028,6 +1086,8 @@ class DeveloperView(RPCBase):
|
|||||||
class DeviceBrowser(RPCBase):
|
class DeviceBrowser(RPCBase):
|
||||||
"""DeviceBrowser is a widget that displays all available devices in the current BEC session."""
|
"""DeviceBrowser is a widget that displays all available devices in the current BEC session."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.services.device_browser.device_browser"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -1050,6 +1110,8 @@ class DeviceBrowser(RPCBase):
|
|||||||
class DeviceInitializationProgressBar(RPCBase):
|
class DeviceInitializationProgressBar(RPCBase):
|
||||||
"""A progress bar that displays the progress of device initialization."""
|
"""A progress bar that displays the progress of device initialization."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -1072,6 +1134,8 @@ class DeviceInitializationProgressBar(RPCBase):
|
|||||||
class DeviceInputBase(RPCBase):
|
class DeviceInputBase(RPCBase):
|
||||||
"""Mixin base class for device input widgets."""
|
"""Mixin base class for device input widgets."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.control.device_input.base_classes.device_input_base"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -1094,6 +1158,8 @@ class DeviceInputBase(RPCBase):
|
|||||||
class DeviceManagerView(RPCBase):
|
class DeviceManagerView(RPCBase):
|
||||||
"""A view for users to manage devices within the application."""
|
"""A view for users to manage devices within the application."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.applications.views.device_manager_view.device_manager_view"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def activate(self) -> "None":
|
def activate(self) -> "None":
|
||||||
"""
|
"""
|
||||||
@@ -1104,6 +1170,8 @@ class DeviceManagerView(RPCBase):
|
|||||||
class DockAreaView(RPCBase):
|
class DockAreaView(RPCBase):
|
||||||
"""Modular dock area view for arranging and managing multiple dockable widgets."""
|
"""Modular dock area view for arranging and managing multiple dockable widgets."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.applications.views.dock_area_view.dock_area_view"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def activate(self) -> "None":
|
def activate(self) -> "None":
|
||||||
"""
|
"""
|
||||||
@@ -1296,10 +1364,10 @@ class DockAreaView(RPCBase):
|
|||||||
Save the current workspace profile.
|
Save the current workspace profile.
|
||||||
|
|
||||||
On first save of a given name:
|
On first save of a given name:
|
||||||
- writes a default copy to states/default/<name>.ini with tag=default and created_at
|
- writes a baseline copy to profiles/baseline/<name>.ini with created_at
|
||||||
- writes a user copy to states/user/<name>.ini with tag=user and created_at
|
- writes a runtime copy to profiles/runtime/<name>.ini with created_at
|
||||||
On subsequent saves of user-owned profiles:
|
On subsequent saves:
|
||||||
- updates both the default and user copies so restore uses the latest snapshot.
|
- updates both the baseline and runtime copies so restore uses the latest snapshot.
|
||||||
Read-only bundled profiles cannot be overwritten.
|
Read-only bundled profiles cannot be overwritten.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -1314,15 +1382,31 @@ class DockAreaView(RPCBase):
|
|||||||
|
|
||||||
@rpc_timeout(None)
|
@rpc_timeout(None)
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def load_profile(self, name: "str | None" = None):
|
def load_profile(self, name: "str | None" = None, restore_baseline: "bool" = False):
|
||||||
"""
|
"""
|
||||||
Load a workspace profile.
|
Load a workspace profile.
|
||||||
|
|
||||||
Before switching, persist the current profile to the user copy.
|
Before switching, persist the current profile to the runtime copy.
|
||||||
Prefer loading the user copy; fall back to the default copy.
|
Prefer loading the runtime copy; fall back to the baseline copy. When
|
||||||
|
``restore_baseline`` is True, first overwrite the runtime copy with the
|
||||||
|
baseline profile and then load it.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str | None): The name of the profile to load. If None, prompts the user.
|
name (str | None): The name of the profile to load. If None, prompts the user.
|
||||||
|
restore_baseline (bool): If True, restore the runtime copy from the
|
||||||
|
baseline before loading. Defaults to False.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@rpc_timeout(None)
|
||||||
|
@rpc_call
|
||||||
|
def restore_baseline_profile(self, name: "str | None" = None, show_dialog: "bool" = False):
|
||||||
|
"""
|
||||||
|
Overwrite the runtime copy of *name* with the baseline.
|
||||||
|
If *name* is None, target the currently active profile.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name (str | None): The name of the profile to restore. If None, uses the current profile.
|
||||||
|
show_dialog (bool): If True, ask for confirmation before restoring.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
@@ -1347,6 +1431,8 @@ class DockAreaView(RPCBase):
|
|||||||
class DockAreaWidget(RPCBase):
|
class DockAreaWidget(RPCBase):
|
||||||
"""Lightweight dock area that exposes the core Qt ADS docking helpers without any"""
|
"""Lightweight dock area that exposes the core Qt ADS docking helpers without any"""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.containers.dock_area.basic_dock_area"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def new(
|
def new(
|
||||||
self,
|
self,
|
||||||
@@ -1531,6 +1617,8 @@ class DockAreaWidget(RPCBase):
|
|||||||
class EllipticalROI(RPCBase):
|
class EllipticalROI(RPCBase):
|
||||||
"""Elliptical Region of Interest with centre/width/height tracking and auto-labelling."""
|
"""Elliptical Region of Interest with centre/width/height tracking and auto-labelling."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def label(self) -> "str":
|
def label(self) -> "str":
|
||||||
@@ -1653,6 +1741,8 @@ class EllipticalROI(RPCBase):
|
|||||||
class Heatmap(RPCBase):
|
class Heatmap(RPCBase):
|
||||||
"""Heatmap widget for visualizing 2d grid data with color mapping for the z-axis."""
|
"""Heatmap widget for visualizing 2d grid data with color mapping for the z-axis."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.plots.heatmap.heatmap"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -2351,6 +2441,8 @@ class Heatmap(RPCBase):
|
|||||||
class Image(RPCBase):
|
class Image(RPCBase):
|
||||||
"""Image widget for displaying 2D data."""
|
"""Image widget for displaying 2D data."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.plots.image.image"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -2962,6 +3054,8 @@ class Image(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class ImageItem(RPCBase):
|
class ImageItem(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.plots.image.image_item"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def color_map(self) -> "str":
|
def color_map(self) -> "str":
|
||||||
@@ -3112,6 +3206,8 @@ class ImageItem(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class LaunchWindow(RPCBase):
|
class LaunchWindow(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.applications.launch_window"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def show_launcher(self):
|
def show_launcher(self):
|
||||||
"""
|
"""
|
||||||
@@ -3126,33 +3222,38 @@ class LaunchWindow(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class LogPanel(RPCBase):
|
class LogPanel(RPCBase):
|
||||||
"""Displays a log panel"""
|
"""Live display of the BEC logs in a table view."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.utility.logpanel.logpanel"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_plain_text(self, text: str) -> None:
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
Set the plain text of the widget.
|
Cleanup the BECConnector
|
||||||
|
|
||||||
Args:
|
|
||||||
text (str): The text to set.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_html_text(self, text: str) -> None:
|
def attach(self):
|
||||||
|
"""
|
||||||
|
None
|
||||||
"""
|
"""
|
||||||
Set the HTML text of the widget.
|
|
||||||
|
|
||||||
Args:
|
@rpc_call
|
||||||
text (str): The text to set.
|
def detach(self):
|
||||||
|
"""
|
||||||
|
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class Minesweeper(RPCBase): ...
|
class Minesweeper(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.games.minesweeper"
|
||||||
|
|
||||||
|
|
||||||
class MonacoDock(RPCBase):
|
class MonacoDock(RPCBase):
|
||||||
"""MonacoDock is a dock widget that contains Monaco editor instances."""
|
"""MonacoDock is a dock widget that contains Monaco editor instances."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.editors.monaco.monaco_dock"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def new(
|
def new(
|
||||||
self,
|
self,
|
||||||
@@ -3337,6 +3438,8 @@ class MonacoDock(RPCBase):
|
|||||||
class MonacoWidget(RPCBase):
|
class MonacoWidget(RPCBase):
|
||||||
"""A simple Monaco editor widget"""
|
"""A simple Monaco editor widget"""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.editors.monaco.monaco_widget"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_text(
|
def set_text(
|
||||||
self, text: "str", file_name: "str | None" = None, reset: "bool" = False
|
self, text: "str", file_name: "str | None" = None, reset: "bool" = False
|
||||||
@@ -3511,6 +3614,8 @@ class MonacoWidget(RPCBase):
|
|||||||
class MotorMap(RPCBase):
|
class MotorMap(RPCBase):
|
||||||
"""Motor map widget for plotting motor positions in 2D including a trace of the last points."""
|
"""Motor map widget for plotting motor positions in 2D including a trace of the last points."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.plots.motor_map.motor_map"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -3981,6 +4086,8 @@ class MotorMap(RPCBase):
|
|||||||
class MultiWaveform(RPCBase):
|
class MultiWaveform(RPCBase):
|
||||||
"""MultiWaveform widget for displaying multiple waveforms emitted by a single signal."""
|
"""MultiWaveform widget for displaying multiple waveforms emitted by a single signal."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.plots.multi_waveform.multi_waveform"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -4440,6 +4547,8 @@ class MultiWaveform(RPCBase):
|
|||||||
class PdfViewerWidget(RPCBase):
|
class PdfViewerWidget(RPCBase):
|
||||||
"""A widget to display PDF documents with toolbar controls."""
|
"""A widget to display PDF documents with toolbar controls."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.utility.pdf_viewer.pdf_viewer"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def load_pdf(self, file_path: str):
|
def load_pdf(self, file_path: str):
|
||||||
"""
|
"""
|
||||||
@@ -4571,6 +4680,10 @@ class PdfViewerWidget(RPCBase):
|
|||||||
class PositionIndicator(RPCBase):
|
class PositionIndicator(RPCBase):
|
||||||
"""Display a position within a defined range, e.g. motor limits."""
|
"""Display a position within a defined range, e.g. motor limits."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = (
|
||||||
|
"bec_widgets.widgets.control.device_control.position_indicator.position_indicator"
|
||||||
|
)
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_value(self, position: float):
|
def set_value(self, position: float):
|
||||||
"""
|
"""
|
||||||
@@ -4636,6 +4749,10 @@ class PositionIndicator(RPCBase):
|
|||||||
class PositionerBox(RPCBase):
|
class PositionerBox(RPCBase):
|
||||||
"""Simple Widget to control a positioner in box form"""
|
"""Simple Widget to control a positioner in box form"""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = (
|
||||||
|
"bec_widgets.widgets.control.device_control.positioner_box.positioner_box.positioner_box"
|
||||||
|
)
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_positioner(self, positioner: "str | Positioner"):
|
def set_positioner(self, positioner: "str | Positioner"):
|
||||||
"""
|
"""
|
||||||
@@ -4668,6 +4785,8 @@ class PositionerBox(RPCBase):
|
|||||||
class PositionerBox2D(RPCBase):
|
class PositionerBox2D(RPCBase):
|
||||||
"""Simple Widget to control two positioners in box form"""
|
"""Simple Widget to control two positioners in box form"""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.control.device_control.positioner_box.positioner_box_2d.positioner_box_2d"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_positioner_hor(self, positioner: "str | Positioner"):
|
def set_positioner_hor(self, positioner: "str | Positioner"):
|
||||||
"""
|
"""
|
||||||
@@ -4737,6 +4856,8 @@ class PositionerBox2D(RPCBase):
|
|||||||
class PositionerControlLine(RPCBase):
|
class PositionerControlLine(RPCBase):
|
||||||
"""A widget that controls a single device."""
|
"""A widget that controls a single device."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_positioner(self, positioner: "str | Positioner"):
|
def set_positioner(self, positioner: "str | Positioner"):
|
||||||
"""
|
"""
|
||||||
@@ -4769,6 +4890,8 @@ class PositionerControlLine(RPCBase):
|
|||||||
class PositionerGroup(RPCBase):
|
class PositionerGroup(RPCBase):
|
||||||
"""Simple Widget to control a positioner in box form"""
|
"""Simple Widget to control a positioner in box form"""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.control.device_control.positioner_group.positioner_group"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_positioners(self, device_names: "str"):
|
def set_positioners(self, device_names: "str"):
|
||||||
"""
|
"""
|
||||||
@@ -4800,6 +4923,8 @@ class PositionerGroup(RPCBase):
|
|||||||
class RectangularROI(RPCBase):
|
class RectangularROI(RPCBase):
|
||||||
"""Defines a rectangular Region of Interest (ROI) with additional functionality."""
|
"""Defines a rectangular Region of Interest (ROI) with additional functionality."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.plots.roi.image_roi"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def label(self) -> "str":
|
def label(self) -> "str":
|
||||||
@@ -4929,6 +5054,8 @@ class RectangularROI(RPCBase):
|
|||||||
class ResumeButton(RPCBase):
|
class ResumeButton(RPCBase):
|
||||||
"""A button that continue scan queue."""
|
"""A button that continue scan queue."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.control.buttons.button_resume.button_resume"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -4949,6 +5076,8 @@ class ResumeButton(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class Ring(RPCBase):
|
class Ring(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.progress.ring_progress_bar.ring"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_value(self, value: "int | float"):
|
def set_value(self, value: "int | float"):
|
||||||
"""
|
"""
|
||||||
@@ -5042,6 +5171,8 @@ class Ring(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class RingProgressBar(RPCBase):
|
class RingProgressBar(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -5121,12 +5252,14 @@ class RingProgressBar(RPCBase):
|
|||||||
class SBBMonitor(RPCBase):
|
class SBBMonitor(RPCBase):
|
||||||
"""A widget to display the SBB monitor website."""
|
"""A widget to display the SBB monitor website."""
|
||||||
|
|
||||||
...
|
_IMPORT_MODULE = "bec_widgets.widgets.editors.sbb_monitor.sbb_monitor"
|
||||||
|
|
||||||
|
|
||||||
class ScanControl(RPCBase):
|
class ScanControl(RPCBase):
|
||||||
"""Widget to submit new scans to the queue."""
|
"""Widget to submit new scans to the queue."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.control.scan_control.scan_control"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def attach(self):
|
def attach(self):
|
||||||
"""
|
"""
|
||||||
@@ -5150,6 +5283,8 @@ class ScanControl(RPCBase):
|
|||||||
class ScanProgressBar(RPCBase):
|
class ScanProgressBar(RPCBase):
|
||||||
"""Widget to display a progress bar that is hooked up to the scan progress of a scan."""
|
"""Widget to display a progress bar that is hooked up to the scan progress of a scan."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.progress.scan_progressbar.scan_progressbar"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -5172,6 +5307,8 @@ class ScanProgressBar(RPCBase):
|
|||||||
class ScatterCurve(RPCBase):
|
class ScatterCurve(RPCBase):
|
||||||
"""Scatter curve item for the scatter waveform widget."""
|
"""Scatter curve item for the scatter waveform widget."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.plots.scatter_waveform.scatter_curve"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def color_map(self) -> "str":
|
def color_map(self) -> "str":
|
||||||
@@ -5181,6 +5318,8 @@ class ScatterCurve(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class ScatterWaveform(RPCBase):
|
class ScatterWaveform(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.plots.scatter_waveform.scatter_waveform"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -5648,6 +5787,8 @@ class ScatterWaveform(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class SignalLabel(RPCBase):
|
class SignalLabel(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.utility.signal_label.signal_label"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def custom_label(self) -> "str":
|
def custom_label(self) -> "str":
|
||||||
@@ -5792,6 +5933,8 @@ class SignalLabel(RPCBase):
|
|||||||
class TextBox(RPCBase):
|
class TextBox(RPCBase):
|
||||||
"""A widget that displays text in plain and HTML format"""
|
"""A widget that displays text in plain and HTML format"""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.editors.text_box.text_box"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_plain_text(self, text: str) -> None:
|
def set_plain_text(self, text: str) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -5814,6 +5957,8 @@ class TextBox(RPCBase):
|
|||||||
class ViewBase(RPCBase):
|
class ViewBase(RPCBase):
|
||||||
"""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."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.applications.views.view"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def activate(self) -> "None":
|
def activate(self) -> "None":
|
||||||
"""
|
"""
|
||||||
@@ -5824,6 +5969,8 @@ class ViewBase(RPCBase):
|
|||||||
class Waveform(RPCBase):
|
class Waveform(RPCBase):
|
||||||
"""Widget for plotting waveforms."""
|
"""Widget for plotting waveforms."""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.plots.waveform.waveform"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
@@ -6402,6 +6549,8 @@ class Waveform(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class WaveformViewInline(RPCBase):
|
class WaveformViewInline(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.applications.views.view"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def activate(self) -> "None":
|
def activate(self) -> "None":
|
||||||
"""
|
"""
|
||||||
@@ -6410,6 +6559,8 @@ class WaveformViewInline(RPCBase):
|
|||||||
|
|
||||||
|
|
||||||
class WaveformViewPopup(RPCBase):
|
class WaveformViewPopup(RPCBase):
|
||||||
|
_IMPORT_MODULE = "bec_widgets.applications.views.view"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def activate(self) -> "None":
|
def activate(self) -> "None":
|
||||||
"""
|
"""
|
||||||
@@ -6417,31 +6568,11 @@ class WaveformViewPopup(RPCBase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class WebConsole(RPCBase):
|
|
||||||
"""A simple widget to display a website"""
|
|
||||||
|
|
||||||
@rpc_call
|
|
||||||
def remove(self):
|
|
||||||
"""
|
|
||||||
Cleanup the BECConnector
|
|
||||||
"""
|
|
||||||
|
|
||||||
@rpc_call
|
|
||||||
def attach(self):
|
|
||||||
"""
|
|
||||||
None
|
|
||||||
"""
|
|
||||||
|
|
||||||
@rpc_call
|
|
||||||
def detach(self):
|
|
||||||
"""
|
|
||||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class WebsiteWidget(RPCBase):
|
class WebsiteWidget(RPCBase):
|
||||||
"""A simple widget to display a website"""
|
"""A simple widget to display a website"""
|
||||||
|
|
||||||
|
_IMPORT_MODULE = "bec_widgets.widgets.editors.website.website"
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def set_url(self, url: str) -> None:
|
def set_url(self, url: str) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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, Literal, TypeAlias, cast
|
from typing import TYPE_CHECKING, Callable, Literal, TypeAlias, cast
|
||||||
|
|
||||||
from bec_lib.endpoints import MessageEndpoints
|
from bec_lib.endpoints import EndpointInfo, 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,6 +232,11 @@ 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
|
||||||
@@ -247,10 +252,9 @@ class BECGuiClient(RPCBase):
|
|||||||
self._ipython_registry = {}
|
self._ipython_registry = {}
|
||||||
|
|
||||||
# Register the new callback
|
# Register the new callback
|
||||||
self._client.connector.register(
|
self._safe_register_stream(
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -531,20 +535,14 @@ class BECGuiClient(RPCBase):
|
|||||||
|
|
||||||
def _start(self, wait: bool = False) -> None:
|
def _start(self, wait: bool = False) -> None:
|
||||||
self._killed = False
|
self._killed = False
|
||||||
self._client.connector.register(
|
self._safe_register_stream(
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
return self._start_server(wait=wait)
|
return self._start_server(wait=wait)
|
||||||
|
|
||||||
@staticmethod
|
def _handle_registry_update(self, msg: dict[str, GUIRegistryStateMessage]) -> None:
|
||||||
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)
|
||||||
|
|
||||||
|
|||||||
@@ -248,9 +248,7 @@ 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),
|
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
|
||||||
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)
|
||||||
@@ -276,11 +274,10 @@ 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)
|
||||||
|
|
||||||
@staticmethod
|
def _on_rpc_response(self, msg_obj: MessageObject) -> None:
|
||||||
def _on_rpc_response(msg_obj: MessageObject, parent: RPCBase) -> None:
|
|
||||||
msg = cast(messages.RequestResponseMessage, msg_obj.value)
|
msg = cast(messages.RequestResponseMessage, msg_obj.value)
|
||||||
parent._rpc_response = msg
|
self._rpc_response = msg
|
||||||
parent._msg_wait_event.set()
|
self._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:
|
||||||
|
|||||||
@@ -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,13 +1 @@
|
|||||||
from qtpy.QtWebEngineWidgets import QWebEngineView
|
|
||||||
|
|
||||||
from .bec_connector import BECConnector, ConnectionConfig
|
|
||||||
from .bec_dispatcher import BECDispatcher
|
|
||||||
from .bec_table import BECTable
|
|
||||||
from .colors import Colors
|
|
||||||
from .container_utils import WidgetContainerUtils
|
|
||||||
from .crosshair import Crosshair
|
|
||||||
from .entry_validator import EntryValidator
|
|
||||||
from .layout_manager import GridLayoutManager
|
|
||||||
from .rpc_decorator import register_rpc_methods, rpc_public
|
|
||||||
from .ui_loader import UILoader
|
|
||||||
from .validator_delegate import DoubleValidationDelegate
|
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ from pydantic import BaseModel, Field, field_validator
|
|||||||
from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, Signal
|
from qtpy.QtCore import Property, QObject, QRunnable, QThreadPool, Signal
|
||||||
from qtpy.QtWidgets import QApplication
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
|
||||||
from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot
|
from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot
|
||||||
from bec_widgets.utils.name_utils import sanitize_namespace
|
from bec_widgets.utils.name_utils import sanitize_namespace
|
||||||
|
from bec_widgets.utils.rpc_register import RPCRegister
|
||||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||||
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
|
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
|
||||||
|
|
||||||
@@ -167,7 +167,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 revisted since
|
# If the gui_id is passed, it should be respected. However, this should be revisited 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
|
||||||
@@ -399,7 +399,7 @@ class BECConnector:
|
|||||||
"""
|
"""
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
# FIXME some thoughts are required to decide how thhis should work with rpc registry
|
# FIXME some thoughts are required to decide how this 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 +417,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 thhis should work with rpc registry
|
# FIXME some thoughts are required to decide how this 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,12 +175,15 @@ 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 qt_slot not in self._registered_slots:
|
if not self.client.connector.any_stream_is_registered(topics, qt_slot):
|
||||||
self._registered_slots[qt_slot] = qt_slot
|
if qt_slot not in self._registered_slots:
|
||||||
qt_slot = self._registered_slots[qt_slot]
|
self._registered_slots[qt_slot] = qt_slot
|
||||||
self.client.connector.register(topics, cb=qt_slot, **kwargs)
|
qt_slot = self._registered_slots[qt_slot]
|
||||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
self.client.connector.register(topics, cb=qt_slot, **kwargs)
|
||||||
qt_slot.topics.update(set(topics_str))
|
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||||
|
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]
|
||||||
|
|||||||
@@ -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 arbritrary!
|
while ii < 1000: # 1000 is arbitrary!
|
||||||
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
|
||||||
|
|||||||
@@ -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 genrated from pydantic models, but can also be composed from other sources or by hand.
|
forms generated 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 appply any
|
"""Add the main data entry widget to self._main_widget and apply any
|
||||||
constraints from the field info"""
|
constraints from the field info"""
|
||||||
|
|
||||||
@SafeSlot()
|
@SafeSlot()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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
|
||||||
@@ -18,20 +19,6 @@ 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):
|
||||||
@@ -54,7 +41,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_all_plugin_widgets, get_plugin_client_module" if self._base else ""}
|
{"from bec_widgets.utils.bec_plugin_helper import get_plugin_client_module" if self._base else ""}
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
|
|
||||||
@@ -94,7 +81,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 programatically \"\"\"
|
\"\"\" Enum for the available widgets, to be generated programmatically \"\"\"
|
||||||
...
|
...
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -111,27 +98,19 @@ _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} from {conflicting_file} conflicts with a built-in class!"
|
f"Plugin widget {plugin_name} in {plugin_class._IMPORT_MODULE} conflicts with a built-in class!"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if plugin_name not in _overlap:
|
else:
|
||||||
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))}")
|
||||||
"""
|
"""
|
||||||
@@ -146,12 +125,8 @@ except ImportError as e:
|
|||||||
|
|
||||||
class_name = cls.__name__
|
class_name = cls.__name__
|
||||||
|
|
||||||
if class_name == "BECDockArea":
|
self.content += f"""
|
||||||
self.content += f"""
|
class {class_name}(RPCBase):\n"""
|
||||||
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
|
||||||
@@ -162,13 +137,9 @@ class {class_name}(RPCBase):"""
|
|||||||
else:
|
else:
|
||||||
class_docs = cls.__doc__.split("\n")[1]
|
class_docs = cls.__doc__.split("\n")[1]
|
||||||
self.content += f"""
|
self.content += f"""
|
||||||
\"\"\"{class_docs}\"\"\"
|
\"\"\"{class_docs}\"\"\"\n"""
|
||||||
"""
|
|
||||||
user_access_entries = self._get_user_access_entries(cls)
|
user_access_entries = self._get_user_access_entries(cls)
|
||||||
if not user_access_entries:
|
self.content += f' _IMPORT_MODULE="{cls.__module__}"\n'
|
||||||
self.content += """...
|
|
||||||
"""
|
|
||||||
|
|
||||||
for method_entry in user_access_entries:
|
for method_entry in user_access_entries:
|
||||||
method, obj, is_property_setter = self._resolve_method_object(cls, method_entry)
|
method, obj, is_property_setter = self._resolve_method_object(cls, method_entry)
|
||||||
if obj is None:
|
if obj is None:
|
||||||
@@ -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 atttribute to decide whether to
|
A Device examines its components' .kind attribute 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
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ 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 QWidget
|
||||||
|
|
||||||
from bec_widgets.utils import BECConnector
|
from bec_widgets.utils.bec_connector 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
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ from qtpy.QtCore import Qt, QTimer
|
|||||||
from qtpy.QtWidgets import QWidget
|
from qtpy.QtWidgets import QWidget
|
||||||
from redis.exceptions import RedisError
|
from redis.exceptions import RedisError
|
||||||
|
|
||||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
|
||||||
from bec_widgets.utils import BECDispatcher
|
|
||||||
from bec_widgets.utils.bec_connector import BECConnector
|
from bec_widgets.utils.bec_connector import BECConnector
|
||||||
|
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||||
from bec_widgets.utils.error_popups import ErrorPopupUtility
|
from bec_widgets.utils.error_popups import ErrorPopupUtility
|
||||||
|
from bec_widgets.utils.rpc_register import RPCRegister
|
||||||
from bec_widgets.utils.screen_utils import apply_window_geometry
|
from bec_widgets.utils.screen_utils import apply_window_geometry
|
||||||
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
|
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
|
||||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC
|
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC
|
||||||
@@ -156,7 +156,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 rethat 9 systems where changing focus is supressed by default
|
# this is a special case for raising windows for gnome on Red Hat (RHEL) 9 systems where changing focus is suppressed 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
|
||||||
@@ -442,5 +442,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("Succeded in shutting down CLI server")
|
logger.info("Succeeded in shutting down CLI server")
|
||||||
self.client.shutdown()
|
self.client.shutdown()
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from qtpy.QtWidgets import (
|
|||||||
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
from bec_widgets.utils import BECConnector
|
from bec_widgets.utils.bec_connector import BECConnector
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
|
|
||||||
@@ -418,7 +418,7 @@ class WidgetHierarchy:
|
|||||||
only_bec_widgets(bool, optional): Whether to print only widgets that are instances of BECWidget.
|
only_bec_widgets(bool, optional): Whether to print only widgets that are instances of BECWidget.
|
||||||
show_parent(bool, optional): Whether to display which BECWidget is the parent of each discovered BECWidget.
|
show_parent(bool, optional): Whether to display which BECWidget is the parent of each discovered BECWidget.
|
||||||
"""
|
"""
|
||||||
from bec_widgets.utils import BECConnector
|
from bec_widgets.utils.bec_connector import BECConnector
|
||||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||||
|
|
||||||
for node in WidgetHierarchy.iter_widget_tree(
|
for node in WidgetHierarchy.iter_widget_tree(
|
||||||
@@ -468,7 +468,7 @@ class WidgetHierarchy:
|
|||||||
|
|
||||||
from qtpy.QtWidgets import QApplication
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
from bec_widgets.utils import BECConnector
|
from bec_widgets.utils.bec_connector import BECConnector
|
||||||
from bec_widgets.widgets.plots.plot_base import PlotBase
|
from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||||
|
|
||||||
# 1) Gather ALL QWidget-based BECConnector objects
|
# 1) Gather ALL QWidget-based BECConnector objects
|
||||||
@@ -534,7 +534,7 @@ class WidgetHierarchy:
|
|||||||
Returns:
|
Returns:
|
||||||
The nearest ancestor that is a BECConnector, or None if not found.
|
The nearest ancestor that is a BECConnector, or None if not found.
|
||||||
"""
|
"""
|
||||||
from bec_widgets.utils import BECConnector
|
from bec_widgets.utils.bec_connector import BECConnector
|
||||||
|
|
||||||
# Guard against deleted/invalid Qt wrappers
|
# Guard against deleted/invalid Qt wrappers
|
||||||
if not shb.isValid(widget):
|
if not shb.isValid(widget):
|
||||||
@@ -636,7 +636,7 @@ class WidgetHierarchy:
|
|||||||
Return all BECConnector instances whose closest BECConnector ancestor is the given widget,
|
Return all BECConnector instances whose closest BECConnector ancestor is the given widget,
|
||||||
including the widget itself if it is a BECConnector.
|
including the widget itself if it is a BECConnector.
|
||||||
"""
|
"""
|
||||||
from bec_widgets.utils import BECConnector
|
from bec_widgets.utils.bec_connector import BECConnector
|
||||||
|
|
||||||
connectors: list[BECConnector] = []
|
connectors: list[BECConnector] = []
|
||||||
if isinstance(widget, BECConnector):
|
if isinstance(widget, BECConnector):
|
||||||
@@ -664,7 +664,7 @@ class WidgetHierarchy:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from bec_widgets.utils import BECConnector # local import to avoid cycles
|
from bec_widgets.utils.bec_connector import BECConnector # local import to avoid cycles
|
||||||
|
|
||||||
is_bec_target = False
|
is_bec_target = False
|
||||||
if isinstance(ancestor_class, str):
|
if isinstance(ancestor_class, str):
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ from shiboken6 import isValid
|
|||||||
|
|
||||||
import bec_widgets.widgets.containers.qt_ads as QtAds
|
import bec_widgets.widgets.containers.qt_ads as QtAds
|
||||||
from bec_widgets import BECWidget, SafeSlot
|
from bec_widgets import BECWidget, SafeSlot
|
||||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
|
||||||
from bec_widgets.utils.bec_connector import BECConnector
|
from bec_widgets.utils.bec_connector import BECConnector
|
||||||
from bec_widgets.utils.property_editor import PropertyEditor
|
from bec_widgets.utils.property_editor import PropertyEditor
|
||||||
|
from bec_widgets.utils.rpc_widget_handler import widget_handler
|
||||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||||
from bec_widgets.widgets.containers.qt_ads import (
|
from bec_widgets.widgets.containers.qt_ads import (
|
||||||
CDockAreaWidget,
|
CDockAreaWidget,
|
||||||
|
|||||||
@@ -20,10 +20,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.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.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,
|
||||||
@@ -35,25 +35,25 @@ from bec_widgets.utils.widget_state_manager import WidgetStateManager
|
|||||||
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
|
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
|
||||||
from bec_widgets.widgets.containers.dock_area.profile_utils import (
|
from bec_widgets.widgets.containers.dock_area.profile_utils import (
|
||||||
SETTINGS_KEYS,
|
SETTINGS_KEYS,
|
||||||
default_profile_candidates,
|
baseline_profile_candidates,
|
||||||
delete_profile_files,
|
delete_profile_files,
|
||||||
get_last_profile,
|
get_last_profile,
|
||||||
is_profile_read_only,
|
is_profile_read_only,
|
||||||
is_quick_select,
|
is_quick_select,
|
||||||
list_profiles,
|
list_profiles,
|
||||||
list_quick_profiles,
|
list_quick_profiles,
|
||||||
load_default_profile_screenshot,
|
load_baseline_profile_screenshot,
|
||||||
load_user_profile_screenshot,
|
load_runtime_profile_screenshot,
|
||||||
now_iso_utc,
|
now_iso_utc,
|
||||||
open_default_settings,
|
open_baseline_settings,
|
||||||
open_user_settings,
|
open_runtime_settings,
|
||||||
profile_origin,
|
profile_origin,
|
||||||
profile_origin_display,
|
profile_origin_display,
|
||||||
read_manifest,
|
read_manifest,
|
||||||
restore_user_from_default,
|
restore_runtime_from_baseline,
|
||||||
|
runtime_profile_candidates,
|
||||||
set_last_profile,
|
set_last_profile,
|
||||||
set_quick_select,
|
set_quick_select,
|
||||||
user_profile_candidates,
|
|
||||||
write_manifest,
|
write_manifest,
|
||||||
)
|
)
|
||||||
from bec_widgets.widgets.containers.dock_area.settings.dialogs import (
|
from bec_widgets.widgets.containers.dock_area.settings.dialogs import (
|
||||||
@@ -69,7 +69,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.web_console.web_console import BECShell, WebConsole
|
from bec_widgets.widgets.editors.bec_console.bec_console import BecConsole, BECShell
|
||||||
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
|
from bec_widgets.widgets.plots.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
|
||||||
@@ -79,7 +79,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 import LogPanel
|
from bec_widgets.widgets.utility.logpanel.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
|
||||||
@@ -108,6 +108,7 @@ class BECDockArea(DockAreaWidget):
|
|||||||
"list_profiles",
|
"list_profiles",
|
||||||
"save_profile",
|
"save_profile",
|
||||||
"load_profile",
|
"load_profile",
|
||||||
|
"restore_baseline_profile",
|
||||||
"delete_profile",
|
"delete_profile",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -235,11 +236,8 @@ class BECDockArea(DockAreaWidget):
|
|||||||
def _load_initial_profile(self, name: str) -> None:
|
def _load_initial_profile(self, name: str) -> None:
|
||||||
"""Load the initial profile."""
|
"""Load the initial profile."""
|
||||||
self.load_profile(name)
|
self.load_profile(name)
|
||||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
|
||||||
combo.blockSignals(True)
|
|
||||||
if not self._empty_profile_active:
|
if not self._empty_profile_active:
|
||||||
combo.setCurrentText(name)
|
self._set_workspace_combo_text_silent(name)
|
||||||
combo.blockSignals(False)
|
|
||||||
|
|
||||||
def _start_empty_workspace(self) -> None:
|
def _start_empty_workspace(self) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -372,10 +370,11 @@ class BECDockArea(DockAreaWidget):
|
|||||||
"Add Circular ProgressBar",
|
"Add Circular ProgressBar",
|
||||||
"RingProgressBar",
|
"RingProgressBar",
|
||||||
),
|
),
|
||||||
"terminal": (WebConsole.ICON_NAME, "Add Terminal", "WebConsole"),
|
"terminal": (BecConsole.ICON_NAME, "Add Terminal", "BecConsole"),
|
||||||
"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)
|
||||||
@@ -487,9 +486,7 @@ 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 widget_type == "LogPanel":
|
if key == "terminal":
|
||||||
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)
|
||||||
)
|
)
|
||||||
@@ -510,10 +507,7 @@ class BECDockArea(DockAreaWidget):
|
|||||||
for action_id, (_, _, widget_type) in mapping.items():
|
for action_id, (_, _, widget_type) in mapping.items():
|
||||||
flat_action_id = f"flat_{action_id}"
|
flat_action_id = f"flat_{action_id}"
|
||||||
flat_action = self.toolbar.components.get_action(flat_action_id).action
|
flat_action = self.toolbar.components.get_action(flat_action_id).action
|
||||||
if widget_type == "LogPanel":
|
flat_action.triggered.connect(lambda _, t=widget_type: self.new(t))
|
||||||
flat_action.setEnabled(False) # keep disabled per issue #644
|
|
||||||
else:
|
|
||||||
flat_action.triggered.connect(lambda _, t=widget_type: self.new(t))
|
|
||||||
|
|
||||||
_connect_flat_actions(self._ACTION_MAPPINGS["menu_plots"])
|
_connect_flat_actions(self._ACTION_MAPPINGS["menu_plots"])
|
||||||
_connect_flat_actions(self._ACTION_MAPPINGS["menu_devices"])
|
_connect_flat_actions(self._ACTION_MAPPINGS["menu_devices"])
|
||||||
@@ -595,13 +589,13 @@ class BECDockArea(DockAreaWidget):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def profile_namespace(self) -> str | None:
|
def profile_namespace(self) -> str | None:
|
||||||
"""Namespace used to scope user/default profile files for this dock area."""
|
"""Namespace used to scope runtime/baseline profile files for this dock area."""
|
||||||
return self._resolve_profile_namespace()
|
return self._resolve_profile_namespace()
|
||||||
|
|
||||||
def _profile_exists(self, name: str, namespace: str | None) -> bool:
|
def _profile_exists(self, name: str, namespace: str | None) -> bool:
|
||||||
return any(
|
return any(
|
||||||
os.path.exists(path) for path in user_profile_candidates(name, namespace)
|
os.path.exists(path) for path in runtime_profile_candidates(name, namespace)
|
||||||
) or any(os.path.exists(path) for path in default_profile_candidates(name, namespace))
|
) or any(os.path.exists(path) for path in baseline_profile_candidates(name, namespace))
|
||||||
|
|
||||||
def _write_snapshot_to_settings(self, settings, save_preview: bool = True) -> None:
|
def _write_snapshot_to_settings(self, settings, save_preview: bool = True) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -627,35 +621,34 @@ class BECDockArea(DockAreaWidget):
|
|||||||
name: str,
|
name: str,
|
||||||
namespace: str | None,
|
namespace: str | None,
|
||||||
*,
|
*,
|
||||||
write_default: bool = True,
|
write_baseline: bool = True,
|
||||||
write_user: bool = True,
|
write_runtime: bool = True,
|
||||||
save_preview: bool = True,
|
save_preview: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Write profile settings to default and/or user settings files.
|
Write profile settings to baseline and/or runtime settings files.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: The profile name.
|
name: The profile name.
|
||||||
namespace: The profile namespace.
|
namespace: The profile namespace.
|
||||||
write_default: Whether to write to the default settings file.
|
write_baseline: Whether to write to the baseline settings file.
|
||||||
write_user: Whether to write to the user settings file.
|
write_runtime: Whether to write to the runtime settings file.
|
||||||
save_preview: Whether to save a screenshot preview.
|
save_preview: Whether to save a screenshot preview.
|
||||||
"""
|
"""
|
||||||
if write_default:
|
|
||||||
ds = open_default_settings(name, namespace=namespace)
|
|
||||||
self._write_snapshot_to_settings(ds, save_preview=save_preview)
|
|
||||||
if not ds.value(SETTINGS_KEYS["created_at"], ""):
|
|
||||||
ds.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
|
|
||||||
if not ds.value(SETTINGS_KEYS["is_quick_select"], None):
|
|
||||||
ds.setValue(SETTINGS_KEYS["is_quick_select"], True)
|
|
||||||
|
|
||||||
if write_user:
|
def _write_settings(open_settings) -> None:
|
||||||
us = open_user_settings(name, namespace=namespace)
|
settings = open_settings(name, namespace=namespace)
|
||||||
self._write_snapshot_to_settings(us, save_preview=save_preview)
|
self._write_snapshot_to_settings(settings, save_preview=save_preview)
|
||||||
if not us.value(SETTINGS_KEYS["created_at"], ""):
|
if not settings.value(SETTINGS_KEYS["created_at"], ""):
|
||||||
us.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
|
settings.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
|
||||||
if not us.value(SETTINGS_KEYS["is_quick_select"], None):
|
if not settings.value(SETTINGS_KEYS["is_quick_select"], None):
|
||||||
us.setValue(SETTINGS_KEYS["is_quick_select"], True)
|
settings.setValue(SETTINGS_KEYS["is_quick_select"], True)
|
||||||
|
|
||||||
|
if write_baseline:
|
||||||
|
_write_settings(open_baseline_settings)
|
||||||
|
|
||||||
|
if write_runtime:
|
||||||
|
_write_settings(open_runtime_settings)
|
||||||
|
|
||||||
def _finalize_profile_change(self, name: str, namespace: str | None) -> None:
|
def _finalize_profile_change(self, name: str, namespace: str | None) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -673,6 +666,14 @@ class BECDockArea(DockAreaWidget):
|
|||||||
combo = self.toolbar.components.get_action("workspace_combo").widget
|
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||||
combo.refresh_profiles(active_profile=name)
|
combo.refresh_profiles(active_profile=name)
|
||||||
|
|
||||||
|
def _set_workspace_combo_text_silent(self, text: str) -> None:
|
||||||
|
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||||
|
was_blocked = combo.blockSignals(True)
|
||||||
|
try:
|
||||||
|
combo.setCurrentText(text)
|
||||||
|
finally:
|
||||||
|
combo.blockSignals(was_blocked)
|
||||||
|
|
||||||
def _enter_empty_profile_state(self) -> None:
|
def _enter_empty_profile_state(self) -> None:
|
||||||
"""
|
"""
|
||||||
Switch to the transient empty workspace state.
|
Switch to the transient empty workspace state.
|
||||||
@@ -709,10 +710,10 @@ class BECDockArea(DockAreaWidget):
|
|||||||
Save the current workspace profile.
|
Save the current workspace profile.
|
||||||
|
|
||||||
On first save of a given name:
|
On first save of a given name:
|
||||||
- writes a default copy to states/default/<name>.ini with tag=default and created_at
|
- writes a baseline copy to profiles/baseline/<name>.ini with created_at
|
||||||
- writes a user copy to states/user/<name>.ini with tag=user and created_at
|
- writes a runtime copy to profiles/runtime/<name>.ini with created_at
|
||||||
On subsequent saves of user-owned profiles:
|
On subsequent saves:
|
||||||
- updates both the default and user copies so restore uses the latest snapshot.
|
- updates both the baseline and runtime copies so restore uses the latest snapshot.
|
||||||
Read-only bundled profiles cannot be overwritten.
|
Read-only bundled profiles cannot be overwritten.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -776,7 +777,7 @@ class BECDockArea(DockAreaWidget):
|
|||||||
overwrite_existing = origin == "settings"
|
overwrite_existing = origin == "settings"
|
||||||
|
|
||||||
origin_before_save = profile_origin(name, namespace=namespace)
|
origin_before_save = profile_origin(name, namespace=namespace)
|
||||||
overwrite_default = overwrite_existing and origin_before_save == "settings"
|
overwrite_baseline = overwrite_existing and origin_before_save == "settings"
|
||||||
|
|
||||||
# Display saving placeholder in toolbar
|
# Display saving placeholder in toolbar
|
||||||
workspace_combo = self.toolbar.components.get_action("workspace_combo").widget
|
workspace_combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||||
@@ -785,12 +786,12 @@ class BECDockArea(DockAreaWidget):
|
|||||||
workspace_combo.setCurrentIndex(0)
|
workspace_combo.setCurrentIndex(0)
|
||||||
workspace_combo.blockSignals(False)
|
workspace_combo.blockSignals(False)
|
||||||
|
|
||||||
# Write to default and/or user settings
|
# Write to baseline and/or runtime settings
|
||||||
should_write_default = overwrite_default or not any(
|
should_write_baseline = overwrite_baseline or not any(
|
||||||
os.path.exists(path) for path in default_profile_candidates(name, namespace)
|
os.path.exists(path) for path in baseline_profile_candidates(name, namespace)
|
||||||
)
|
)
|
||||||
self._write_profile_settings(
|
self._write_profile_settings(
|
||||||
name, namespace, write_default=should_write_default, write_user=True
|
name, namespace, write_baseline=should_write_baseline, write_runtime=True
|
||||||
)
|
)
|
||||||
|
|
||||||
set_quick_select(name, quickselect, namespace=namespace)
|
set_quick_select(name, quickselect, namespace=namespace)
|
||||||
@@ -800,7 +801,6 @@ class BECDockArea(DockAreaWidget):
|
|||||||
self._pending_autosave_skip = (current_profile, name)
|
self._pending_autosave_skip = (current_profile, name)
|
||||||
else:
|
else:
|
||||||
self._pending_autosave_skip = None
|
self._pending_autosave_skip = None
|
||||||
workspace_combo.setCurrentText(name)
|
|
||||||
self._finalize_profile_change(name, namespace)
|
self._finalize_profile_change(name, namespace)
|
||||||
|
|
||||||
@SafeSlot()
|
@SafeSlot()
|
||||||
@@ -820,16 +820,21 @@ class BECDockArea(DockAreaWidget):
|
|||||||
|
|
||||||
@SafeSlot()
|
@SafeSlot()
|
||||||
@SafeSlot(str)
|
@SafeSlot(str)
|
||||||
|
@SafeSlot(str, bool)
|
||||||
@rpc_timeout(None)
|
@rpc_timeout(None)
|
||||||
def load_profile(self, name: str | None = None):
|
def load_profile(self, name: str | None = None, restore_baseline: bool = False):
|
||||||
"""
|
"""
|
||||||
Load a workspace profile.
|
Load a workspace profile.
|
||||||
|
|
||||||
Before switching, persist the current profile to the user copy.
|
Before switching, persist the current profile to the runtime copy.
|
||||||
Prefer loading the user copy; fall back to the default copy.
|
Prefer loading the runtime copy; fall back to the baseline copy. When
|
||||||
|
``restore_baseline`` is True, first overwrite the runtime copy with the
|
||||||
|
baseline profile and then load it.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str | None): The name of the profile to load. If None, prompts the user.
|
name (str | None): The name of the profile to load. If None, prompts the user.
|
||||||
|
restore_baseline (bool): If True, restore the runtime copy from the
|
||||||
|
baseline before loading. Defaults to False.
|
||||||
"""
|
"""
|
||||||
if name == "":
|
if name == "":
|
||||||
return
|
return
|
||||||
@@ -848,14 +853,17 @@ class BECDockArea(DockAreaWidget):
|
|||||||
if skip_pair and skip_pair == (prev_name, name):
|
if skip_pair and skip_pair == (prev_name, name):
|
||||||
self._pending_autosave_skip = None
|
self._pending_autosave_skip = None
|
||||||
else:
|
else:
|
||||||
us_prev = open_user_settings(prev_name, namespace=namespace)
|
us_prev = open_runtime_settings(prev_name, namespace=namespace)
|
||||||
self._write_snapshot_to_settings(us_prev, save_preview=True)
|
self._write_snapshot_to_settings(us_prev, save_preview=True)
|
||||||
|
|
||||||
|
if restore_baseline:
|
||||||
|
restore_runtime_from_baseline(name, namespace=namespace)
|
||||||
|
|
||||||
settings = None
|
settings = None
|
||||||
if any(os.path.exists(path) for path in user_profile_candidates(name, namespace)):
|
if any(os.path.exists(path) for path in runtime_profile_candidates(name, namespace)):
|
||||||
settings = open_user_settings(name, namespace=namespace)
|
settings = open_runtime_settings(name, namespace=namespace)
|
||||||
elif any(os.path.exists(path) for path in default_profile_candidates(name, namespace)):
|
elif any(os.path.exists(path) for path in baseline_profile_candidates(name, namespace)):
|
||||||
settings = open_default_settings(name, namespace=namespace)
|
settings = open_baseline_settings(name, namespace=namespace)
|
||||||
if settings is None:
|
if settings is None:
|
||||||
logger.warning(f"Profile '{name}' not found in namespace '{namespace}'. Creating new.")
|
logger.warning(f"Profile '{name}' not found in namespace '{namespace}'. Creating new.")
|
||||||
self.delete_all()
|
self.delete_all()
|
||||||
@@ -897,32 +905,36 @@ class BECDockArea(DockAreaWidget):
|
|||||||
|
|
||||||
@SafeSlot()
|
@SafeSlot()
|
||||||
@SafeSlot(str)
|
@SafeSlot(str)
|
||||||
def restore_user_profile_from_default(self, name: str | None = None):
|
@SafeSlot(str, bool)
|
||||||
|
@rpc_timeout(None)
|
||||||
|
def restore_baseline_profile(self, name: str | None = None, show_dialog: bool = False):
|
||||||
"""
|
"""
|
||||||
Overwrite the user copy of *name* with the default baseline.
|
Overwrite the runtime copy of *name* with the baseline.
|
||||||
If *name* is None, target the currently active profile.
|
If *name* is None, target the currently active profile.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str | None): The name of the profile to restore. If None, uses the current profile.
|
name (str | None): The name of the profile to restore. If None, uses the current profile.
|
||||||
|
show_dialog (bool): If True, ask for confirmation before restoring.
|
||||||
"""
|
"""
|
||||||
target = name or getattr(self, "_current_profile_name", None)
|
target = name or getattr(self, "_current_profile_name", None)
|
||||||
if not target:
|
if not target:
|
||||||
return
|
return
|
||||||
namespace = self.profile_namespace
|
namespace = self.profile_namespace
|
||||||
|
|
||||||
current_pixmap = None
|
if show_dialog:
|
||||||
if self.isVisible():
|
current_pixmap = None
|
||||||
current_pixmap = QPixmap()
|
if self.isVisible():
|
||||||
ba = bytes(self.screenshot_bytes())
|
current_pixmap = QPixmap()
|
||||||
current_pixmap.loadFromData(ba)
|
ba = bytes(self.screenshot_bytes())
|
||||||
if current_pixmap is None or current_pixmap.isNull():
|
current_pixmap.loadFromData(ba)
|
||||||
current_pixmap = load_user_profile_screenshot(target, namespace=namespace)
|
if current_pixmap is None or current_pixmap.isNull():
|
||||||
default_pixmap = load_default_profile_screenshot(target, namespace=namespace)
|
current_pixmap = load_runtime_profile_screenshot(target, namespace=namespace)
|
||||||
|
baseline_pixmap = load_baseline_profile_screenshot(target, namespace=namespace)
|
||||||
|
|
||||||
if not RestoreProfileDialog.confirm(self, current_pixmap, default_pixmap):
|
if not RestoreProfileDialog.confirm(self, current_pixmap, baseline_pixmap):
|
||||||
return
|
return
|
||||||
|
|
||||||
restore_user_from_default(target, namespace=namespace)
|
restore_runtime_from_baseline(target, namespace=namespace)
|
||||||
self.delete_all()
|
self.delete_all()
|
||||||
self.load_profile(target)
|
self.load_profile(target)
|
||||||
|
|
||||||
@@ -1057,7 +1069,7 @@ class BECDockArea(DockAreaWidget):
|
|||||||
manage_action = self.toolbar.components.get_action("manage_workspaces").action
|
manage_action = self.toolbar.components.get_action("manage_workspaces").action
|
||||||
if self.manage_dialog is None or not self.manage_dialog.isVisible():
|
if self.manage_dialog is None or not self.manage_dialog.isVisible():
|
||||||
self.manage_widget = WorkSpaceManager(
|
self.manage_widget = WorkSpaceManager(
|
||||||
self, target_widget=self, default_profile=self._current_profile_name
|
self, target_widget=self, active_profile=self._current_profile_name
|
||||||
)
|
)
|
||||||
self.manage_dialog = QDialog(modal=False)
|
self.manage_dialog = QDialog(modal=False)
|
||||||
|
|
||||||
@@ -1156,7 +1168,7 @@ class BECDockArea(DockAreaWidget):
|
|||||||
return
|
return
|
||||||
|
|
||||||
namespace = self.profile_namespace
|
namespace = self.profile_namespace
|
||||||
settings = open_user_settings(name, namespace=namespace)
|
settings = open_runtime_settings(name, namespace=namespace)
|
||||||
self._write_snapshot_to_settings(settings)
|
self._write_snapshot_to_settings(settings)
|
||||||
set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id())
|
set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id())
|
||||||
self._exit_snapshot_written = True
|
self._exit_snapshot_written = True
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
Utilities for managing BECDockArea profiles stored in INI files.
|
Utilities for managing BECDockArea profiles stored in INI files.
|
||||||
|
|
||||||
Policy:
|
Policy:
|
||||||
- All created/modified profiles are stored under the BEC settings root: <base_path>/profiles/{default,user}
|
- All created/modified profiles are stored under the BEC settings root:
|
||||||
- Bundled read-only defaults are discovered in BW core states/default and plugin bec_widgets/profiles but never written to.
|
<base_path>/profiles/{baseline,runtime}
|
||||||
- Lookup order when reading: user → settings default → app or plugin bundled default.
|
- Bundled read-only baselines are discovered in BW core profiles and plugin
|
||||||
|
bec_widgets/profiles but never written to.
|
||||||
|
- Lookup order when reading: runtime → settings baseline → app or plugin bundled baseline.
|
||||||
|
- Legacy settings paths profiles/{default,user} are read through a thin segment
|
||||||
|
alias layer and copied to the canonical location on first access.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -32,6 +36,12 @@ logger = bec_logger.logger
|
|||||||
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
ProfileOrigin = Literal["module", "plugin", "settings", "unknown"]
|
ProfileOrigin = Literal["module", "plugin", "settings", "unknown"]
|
||||||
|
ProfileSegment = Literal["baseline", "runtime"]
|
||||||
|
|
||||||
|
_PROFILE_SEGMENT_ALIASES: dict[ProfileSegment, tuple[str, str]] = {
|
||||||
|
"baseline": ("baseline", "default"),
|
||||||
|
"runtime": ("runtime", "user"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def module_profiles_dir() -> str:
|
def module_profiles_dir() -> str:
|
||||||
@@ -130,7 +140,7 @@ def _profiles_dir(segment: str, namespace: str | None) -> str:
|
|||||||
Build (and ensure) the directory that holds profiles for a namespace segment.
|
Build (and ensure) the directory that holds profiles for a namespace segment.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
segment (str): Either ``"user"`` or ``"default"``.
|
segment (str): Profile segment directory name.
|
||||||
namespace (str | None): Optional namespace label to scope profiles.
|
namespace (str | None): Optional namespace label to scope profiles.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -143,157 +153,175 @@ def _profiles_dir(segment: str, namespace: str | None) -> str:
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def _user_path_candidates(name: str, namespace: str | None) -> list[str]:
|
def _candidate_namespaces(namespace: str | None) -> list[str | None]:
|
||||||
"""
|
|
||||||
Generate candidate user-profile paths honoring namespace fallbacks.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name (str): Profile name without extension.
|
|
||||||
namespace (str | None): Optional namespace label.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[str]: Ordered list of candidate user profile paths (.ini files).
|
|
||||||
"""
|
|
||||||
ns = slugify.slugify(namespace, separator="_") if namespace else None
|
ns = slugify.slugify(namespace, separator="_") if namespace else None
|
||||||
primary = os.path.join(_profiles_dir("user", ns), f"{name}.ini")
|
|
||||||
if not ns:
|
if not ns:
|
||||||
return [primary]
|
return [None]
|
||||||
legacy = os.path.join(_profiles_dir("user", None), f"{name}.ini")
|
return [ns, None]
|
||||||
return [primary, legacy] if legacy != primary else [primary]
|
|
||||||
|
|
||||||
|
|
||||||
def _default_path_candidates(name: str, namespace: str | None) -> list[str]:
|
def _segment_profile_path(segment_name: str, name: str, namespace: str | None) -> str:
|
||||||
|
return os.path.join(_profiles_dir(segment_name, namespace), f"{name}.ini")
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_profile_path(segment: ProfileSegment, name: str, namespace: str | None) -> str:
|
||||||
|
return _segment_profile_path(_PROFILE_SEGMENT_ALIASES[segment][0], name, namespace)
|
||||||
|
|
||||||
|
|
||||||
|
def _segment_path_candidates(
|
||||||
|
segment: ProfileSegment,
|
||||||
|
name: str,
|
||||||
|
namespace: str | None,
|
||||||
|
*,
|
||||||
|
include_legacy: bool = True,
|
||||||
|
migrate_legacy: bool = True,
|
||||||
|
) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Generate candidate default-profile paths honoring namespace fallbacks.
|
Generate profile candidates for a canonical segment.
|
||||||
|
|
||||||
Args:
|
Canonical baseline/runtime files are always preferred. Namespace fallback
|
||||||
name (str): Profile name without extension.
|
files and legacy default/user files are copied to the primary canonical path
|
||||||
namespace (str | None): Optional namespace label.
|
when the primary file does not exist.
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[str]: Ordered list of candidate default profile paths (.ini files).
|
|
||||||
"""
|
"""
|
||||||
ns = slugify.slugify(namespace, separator="_") if namespace else None
|
canonical = [
|
||||||
primary = os.path.join(_profiles_dir("default", ns), f"{name}.ini")
|
_segment_profile_path(_PROFILE_SEGMENT_ALIASES[segment][0], name, ns)
|
||||||
if not ns:
|
for ns in _candidate_namespaces(namespace)
|
||||||
return [primary]
|
]
|
||||||
legacy = os.path.join(_profiles_dir("default", None), f"{name}.ini")
|
legacy = []
|
||||||
return [primary, legacy] if legacy != primary else [primary]
|
if include_legacy:
|
||||||
|
legacy = [
|
||||||
|
_segment_profile_path(_PROFILE_SEGMENT_ALIASES[segment][1], name, ns)
|
||||||
|
for ns in _candidate_namespaces(namespace)
|
||||||
|
]
|
||||||
|
|
||||||
|
primary_canonical = canonical[0]
|
||||||
|
if migrate_legacy and not os.path.exists(primary_canonical):
|
||||||
|
canonical_src = next((path for path in canonical[1:] if os.path.exists(path)), None)
|
||||||
|
if canonical_src:
|
||||||
|
os.makedirs(os.path.dirname(primary_canonical), exist_ok=True)
|
||||||
|
shutil.copy2(canonical_src, primary_canonical)
|
||||||
|
elif include_legacy:
|
||||||
|
legacy_src = next((path for path in legacy if os.path.exists(path)), None)
|
||||||
|
if legacy_src:
|
||||||
|
os.makedirs(os.path.dirname(primary_canonical), exist_ok=True)
|
||||||
|
shutil.copy2(legacy_src, primary_canonical)
|
||||||
|
|
||||||
|
return list(dict.fromkeys(canonical + legacy))
|
||||||
|
|
||||||
|
|
||||||
def default_profiles_dir(namespace: str | None = None) -> str:
|
def baseline_profiles_dir(namespace: str | None = None) -> str:
|
||||||
"""
|
"""
|
||||||
Return the directory that stores default profiles for the namespace.
|
Return the directory that stores baseline profiles for the namespace.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Absolute path to the default profile directory.
|
str: Absolute path to the baseline profile directory.
|
||||||
"""
|
"""
|
||||||
return _profiles_dir("default", namespace)
|
return _profiles_dir("baseline", namespace)
|
||||||
|
|
||||||
|
|
||||||
def user_profiles_dir(namespace: str | None = None) -> str:
|
def runtime_profiles_dir(namespace: str | None = None) -> str:
|
||||||
"""
|
"""
|
||||||
Return the directory that stores user profiles for the namespace.
|
Return the directory that stores runtime profiles for the namespace.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Absolute path to the user profile directory.
|
str: Absolute path to the runtime profile directory.
|
||||||
"""
|
"""
|
||||||
return _profiles_dir("user", namespace)
|
return _profiles_dir("runtime", namespace)
|
||||||
|
|
||||||
|
|
||||||
def default_profile_path(name: str, namespace: str | None = None) -> str:
|
def baseline_profile_path(name: str, namespace: str | None = None) -> str:
|
||||||
"""
|
"""
|
||||||
Compute the canonical default profile path for a profile name.
|
Compute the canonical baseline profile path for a profile name.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): Profile name without extension.
|
name (str): Profile name without extension.
|
||||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Absolute path to the default profile file (.ini).
|
str: Absolute path to the baseline profile file (.ini).
|
||||||
"""
|
"""
|
||||||
return _default_path_candidates(name, namespace)[0]
|
return _canonical_profile_path("baseline", name, namespace)
|
||||||
|
|
||||||
|
|
||||||
def user_profile_path(name: str, namespace: str | None = None) -> str:
|
def runtime_profile_path(name: str, namespace: str | None = None) -> str:
|
||||||
"""
|
"""
|
||||||
Compute the canonical user profile path for a profile name.
|
Compute the canonical runtime profile path for a profile name.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): Profile name without extension.
|
name (str): Profile name without extension.
|
||||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Absolute path to the user profile file (.ini).
|
str: Absolute path to the runtime profile file (.ini).
|
||||||
"""
|
"""
|
||||||
return _user_path_candidates(name, namespace)[0]
|
return _canonical_profile_path("runtime", name, namespace)
|
||||||
|
|
||||||
|
|
||||||
def user_profile_candidates(name: str, namespace: str | None = None) -> list[str]:
|
def runtime_profile_candidates(name: str, namespace: str | None = None) -> list[str]:
|
||||||
"""
|
"""
|
||||||
List all user profile path candidates for a profile name.
|
List all runtime profile path candidates for a profile name.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): Profile name without extension.
|
name (str): Profile name without extension.
|
||||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[str]: De-duplicated list of candidate user profile paths.
|
list[str]: De-duplicated list of candidate runtime profile paths.
|
||||||
"""
|
"""
|
||||||
return list(dict.fromkeys(_user_path_candidates(name, namespace)))
|
return _segment_path_candidates("runtime", name, namespace)
|
||||||
|
|
||||||
|
|
||||||
def default_profile_candidates(name: str, namespace: str | None = None) -> list[str]:
|
def baseline_profile_candidates(name: str, namespace: str | None = None) -> list[str]:
|
||||||
"""
|
"""
|
||||||
List all default profile path candidates for a profile name.
|
List all baseline profile path candidates for a profile name.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): Profile name without extension.
|
name (str): Profile name without extension.
|
||||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[str]: De-duplicated list of candidate default profile paths.
|
list[str]: De-duplicated list of candidate baseline profile paths.
|
||||||
"""
|
"""
|
||||||
return list(dict.fromkeys(_default_path_candidates(name, namespace)))
|
return _segment_path_candidates("baseline", name, namespace)
|
||||||
|
|
||||||
|
|
||||||
def _existing_user_settings(name: str, namespace: str | None = None) -> QSettings | None:
|
def _existing_runtime_settings(name: str, namespace: str | None = None) -> QSettings | None:
|
||||||
"""
|
"""
|
||||||
Resolve the first existing user profile settings object.
|
Resolve the first existing runtime profile settings object.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): Profile name without extension.
|
name (str): Profile name without extension.
|
||||||
namespace (str | None, optional): Namespace label to search. Defaults to ``None``.
|
namespace (str | None, optional): Namespace label to search. Defaults to ``None``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
QSettings | None: Config for the first existing user profile candidate, or ``None``
|
QSettings | None: Config for the first existing runtime profile candidate, or ``None``
|
||||||
when no files are present.
|
when no files are present.
|
||||||
"""
|
"""
|
||||||
for path in user_profile_candidates(name, namespace):
|
for path in runtime_profile_candidates(name, namespace):
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
return QSettings(path, QSettings.IniFormat)
|
return QSettings(path, QSettings.IniFormat)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _existing_default_settings(name: str, namespace: str | None = None) -> QSettings | None:
|
def _existing_baseline_settings(name: str, namespace: str | None = None) -> QSettings | None:
|
||||||
"""
|
"""
|
||||||
Resolve the first existing default profile settings object.
|
Resolve the first existing baseline profile settings object.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): Profile name without extension.
|
name (str): Profile name without extension.
|
||||||
namespace (str | None, optional): Namespace label to search. Defaults to ``None``.
|
namespace (str | None, optional): Namespace label to search. Defaults to ``None``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
QSettings | None: Config for the first existing default profile candidate, or ``None``
|
QSettings | None: Config for the first existing baseline profile candidate, or ``None``
|
||||||
when no files are present.
|
when no files are present.
|
||||||
"""
|
"""
|
||||||
for path in default_profile_candidates(name, namespace):
|
for path in baseline_profile_candidates(name, namespace):
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
return QSettings(path, QSettings.IniFormat)
|
return QSettings(path, QSettings.IniFormat)
|
||||||
return None
|
return None
|
||||||
@@ -347,7 +375,7 @@ def profile_origin(name: str, namespace: str | None = None) -> ProfileOrigin:
|
|||||||
plugin_path = plugin_profile_path(name)
|
plugin_path = plugin_profile_path(name)
|
||||||
if plugin_path and os.path.exists(plugin_path):
|
if plugin_path and os.path.exists(plugin_path):
|
||||||
return "plugin"
|
return "plugin"
|
||||||
for path in user_profile_candidates(name, namespace) + default_profile_candidates(
|
for path in runtime_profile_candidates(name, namespace) + baseline_profile_candidates(
|
||||||
name, namespace
|
name, namespace
|
||||||
):
|
):
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
@@ -406,8 +434,8 @@ def delete_profile_files(name: str, namespace: str | None = None) -> bool:
|
|||||||
read_only = is_profile_read_only(name, namespace)
|
read_only = is_profile_read_only(name, namespace)
|
||||||
|
|
||||||
removed = False
|
removed = False
|
||||||
# Always allow removing user copies; keep default copies for read-only origins.
|
# Always allow removing runtime copies; keep baseline copies for read-only origins.
|
||||||
for path in set(user_profile_candidates(name, namespace)):
|
for path in set(runtime_profile_candidates(name, namespace)):
|
||||||
try:
|
try:
|
||||||
os.remove(path)
|
os.remove(path)
|
||||||
removed = True
|
removed = True
|
||||||
@@ -415,7 +443,7 @@ def delete_profile_files(name: str, namespace: str | None = None) -> bool:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if not read_only:
|
if not read_only:
|
||||||
for path in set(default_profile_candidates(name, namespace)):
|
for path in set(baseline_profile_candidates(name, namespace)):
|
||||||
try:
|
try:
|
||||||
os.remove(path)
|
os.remove(path)
|
||||||
removed = True
|
removed = True
|
||||||
@@ -443,7 +471,7 @@ SETTINGS_KEYS = {
|
|||||||
|
|
||||||
def list_profiles(namespace: str | None = None) -> list[str]:
|
def list_profiles(namespace: str | None = None) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Enumerate all known profile names, syncing bundled defaults when missing locally.
|
Enumerate all known profile names, syncing bundled baselines when missing locally.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
namespace (str | None, optional): Namespace label scoped to the profile set.
|
namespace (str | None, optional): Namespace label scoped to the profile set.
|
||||||
@@ -459,16 +487,27 @@ def list_profiles(namespace: str | None = None) -> list[str]:
|
|||||||
return set()
|
return set()
|
||||||
return {os.path.splitext(f)[0] for f in os.listdir(directory) if f.endswith(".ini")}
|
return {os.path.splitext(f)[0] for f in os.listdir(directory) if f.endswith(".ini")}
|
||||||
|
|
||||||
settings_dirs = {default_profiles_dir(namespace), user_profiles_dir(namespace)}
|
settings_dirs = {baseline_profiles_dir(namespace), runtime_profiles_dir(namespace)}
|
||||||
if ns:
|
if ns:
|
||||||
settings_dirs.add(default_profiles_dir(None))
|
settings_dirs.add(baseline_profiles_dir(None))
|
||||||
settings_dirs.add(user_profiles_dir(None))
|
settings_dirs.add(runtime_profiles_dir(None))
|
||||||
|
|
||||||
|
for segment in ("baseline", "runtime"):
|
||||||
|
for legacy_dir in [
|
||||||
|
_profiles_dir(_PROFILE_SEGMENT_ALIASES[segment][1], item)
|
||||||
|
for item in _candidate_namespaces(namespace)
|
||||||
|
]:
|
||||||
|
settings_dirs.add(legacy_dir)
|
||||||
|
|
||||||
settings_names: set[str] = set()
|
settings_names: set[str] = set()
|
||||||
for directory in settings_dirs:
|
for directory in settings_dirs:
|
||||||
settings_names |= _collect_from(directory)
|
settings_names |= _collect_from(directory)
|
||||||
|
|
||||||
# Also consider read-only defaults from core module and beamline plugin repositories
|
for name in sorted(settings_names):
|
||||||
|
runtime_profile_candidates(name, namespace)
|
||||||
|
baseline_profile_candidates(name, namespace)
|
||||||
|
|
||||||
|
# Also consider read-only baselines from core module and beamline plugin repositories
|
||||||
read_only_sources: dict[str, tuple[str, str]] = {}
|
read_only_sources: dict[str, tuple[str, str]] = {}
|
||||||
sources: list[tuple[str, str | None]] = [
|
sources: list[tuple[str, str | None]] = [
|
||||||
("module", module_profiles_dir()),
|
("module", module_profiles_dir()),
|
||||||
@@ -484,17 +523,17 @@ def list_profiles(namespace: str | None = None) -> list[str]:
|
|||||||
read_only_sources.setdefault(name, (origin, os.path.join(directory, filename)))
|
read_only_sources.setdefault(name, (origin, os.path.join(directory, filename)))
|
||||||
|
|
||||||
for name, (_origin, src) in sorted(read_only_sources.items()):
|
for name, (_origin, src) in sorted(read_only_sources.items()):
|
||||||
# Ensure a copy in the namespace-specific settings default directory
|
# Ensure a copy in the namespace-specific settings baseline directory.
|
||||||
dst_default = default_profile_path(name, namespace)
|
dst_baseline = baseline_profile_path(name, namespace)
|
||||||
if not os.path.exists(dst_default):
|
if not os.path.exists(dst_baseline):
|
||||||
os.makedirs(os.path.dirname(dst_default), exist_ok=True)
|
os.makedirs(os.path.dirname(dst_baseline), exist_ok=True)
|
||||||
shutil.copyfile(src, dst_default)
|
shutil.copy2(src, dst_baseline)
|
||||||
# Ensure a user copy exists to allow edits in the writable settings area
|
# Ensure a runtime copy exists to allow edits in the writable settings area.
|
||||||
dst_user = user_profile_path(name, namespace)
|
dst_runtime = runtime_profile_path(name, namespace)
|
||||||
if not os.path.exists(dst_user):
|
if not os.path.exists(dst_runtime):
|
||||||
os.makedirs(os.path.dirname(dst_user), exist_ok=True)
|
os.makedirs(os.path.dirname(dst_runtime), exist_ok=True)
|
||||||
shutil.copyfile(src, dst_user)
|
shutil.copy2(src, dst_runtime)
|
||||||
s = open_user_settings(name, namespace)
|
s = open_runtime_settings(name, namespace)
|
||||||
if s.value(SETTINGS_KEYS["created_at"], "") == "":
|
if s.value(SETTINGS_KEYS["created_at"], "") == "":
|
||||||
s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
|
s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
|
||||||
|
|
||||||
@@ -504,32 +543,34 @@ def list_profiles(namespace: str | None = None) -> list[str]:
|
|||||||
return sorted(settings_names)
|
return sorted(settings_names)
|
||||||
|
|
||||||
|
|
||||||
def open_default_settings(name: str, namespace: str | None = None) -> QSettings:
|
def open_baseline_settings(name: str, namespace: str | None = None) -> QSettings:
|
||||||
"""
|
"""
|
||||||
Open (and create if necessary) the default profile settings file.
|
Open (and create if necessary) the baseline profile settings file.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): Profile name without extension.
|
name (str): Profile name without extension.
|
||||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
QSettings: Settings instance targeting the default profile file.
|
QSettings: Settings instance targeting the baseline profile file.
|
||||||
"""
|
"""
|
||||||
return QSettings(default_profile_path(name, namespace), QSettings.IniFormat)
|
baseline_profile_candidates(name, namespace)
|
||||||
|
return QSettings(baseline_profile_path(name, namespace), QSettings.IniFormat)
|
||||||
|
|
||||||
|
|
||||||
def open_user_settings(name: str, namespace: str | None = None) -> QSettings:
|
def open_runtime_settings(name: str, namespace: str | None = None) -> QSettings:
|
||||||
"""
|
"""
|
||||||
Open (and create if necessary) the user profile settings file.
|
Open (and create if necessary) the runtime profile settings file.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): Profile name without extension.
|
name (str): Profile name without extension.
|
||||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
QSettings: Settings instance targeting the user profile file.
|
QSettings: Settings instance targeting the runtime profile file.
|
||||||
"""
|
"""
|
||||||
return QSettings(user_profile_path(name, namespace), QSettings.IniFormat)
|
runtime_profile_candidates(name, namespace)
|
||||||
|
return QSettings(runtime_profile_path(name, namespace), QSettings.IniFormat)
|
||||||
|
|
||||||
|
|
||||||
def _app_settings() -> QSettings:
|
def _app_settings() -> QSettings:
|
||||||
@@ -759,26 +800,26 @@ def read_manifest(settings: QSettings) -> list[dict]:
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
def restore_user_from_default(name: str, namespace: str | None = None) -> None:
|
def restore_runtime_from_baseline(name: str, namespace: str | None = None) -> None:
|
||||||
"""
|
"""
|
||||||
Copy the default profile to the user profile, preserving quick-select flag.
|
Copy the baseline profile to the runtime profile, preserving quick-select flag.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name(str): Profile name without extension.
|
name(str): Profile name without extension.
|
||||||
namespace(str | None, optional): Namespace label. Defaults to ``None``.
|
namespace(str | None, optional): Namespace label. Defaults to ``None``.
|
||||||
"""
|
"""
|
||||||
src = None
|
src = None
|
||||||
for candidate in default_profile_candidates(name, namespace):
|
for candidate in baseline_profile_candidates(name, namespace):
|
||||||
if os.path.exists(candidate):
|
if os.path.exists(candidate):
|
||||||
src = candidate
|
src = candidate
|
||||||
break
|
break
|
||||||
if not src:
|
if not src:
|
||||||
return
|
return
|
||||||
dst = user_profile_path(name, namespace)
|
dst = runtime_profile_path(name, namespace)
|
||||||
preserve_quick_select = is_quick_select(name, namespace)
|
preserve_quick_select = is_quick_select(name, namespace)
|
||||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||||
shutil.copyfile(src, dst)
|
shutil.copyfile(src, dst)
|
||||||
s = open_user_settings(name, namespace)
|
s = open_runtime_settings(name, namespace)
|
||||||
if not s.value(SETTINGS_KEYS["created_at"], ""):
|
if not s.value(SETTINGS_KEYS["created_at"], ""):
|
||||||
s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
|
s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
|
||||||
if preserve_quick_select:
|
if preserve_quick_select:
|
||||||
@@ -796,9 +837,9 @@ def is_quick_select(name: str, namespace: str | None = None) -> bool:
|
|||||||
Returns:
|
Returns:
|
||||||
bool: True if quick-select is enabled for the profile.
|
bool: True if quick-select is enabled for the profile.
|
||||||
"""
|
"""
|
||||||
s = _existing_user_settings(name, namespace)
|
s = _existing_runtime_settings(name, namespace)
|
||||||
if s is None:
|
if s is None:
|
||||||
s = _existing_default_settings(name, namespace)
|
s = _existing_baseline_settings(name, namespace)
|
||||||
if s is None:
|
if s is None:
|
||||||
return False
|
return False
|
||||||
return s.value(SETTINGS_KEYS["is_quick_select"], False, type=bool)
|
return s.value(SETTINGS_KEYS["is_quick_select"], False, type=bool)
|
||||||
@@ -813,13 +854,13 @@ def set_quick_select(name: str, enabled: bool, namespace: str | None = None) ->
|
|||||||
enabled(bool): True to enable quick-select, False to disable.
|
enabled(bool): True to enable quick-select, False to disable.
|
||||||
namespace(str | None, optional): Namespace label. Defaults to ``None``.
|
namespace(str | None, optional): Namespace label. Defaults to ``None``.
|
||||||
"""
|
"""
|
||||||
s = open_user_settings(name, namespace)
|
s = open_runtime_settings(name, namespace)
|
||||||
s.setValue(SETTINGS_KEYS["is_quick_select"], bool(enabled))
|
s.setValue(SETTINGS_KEYS["is_quick_select"], bool(enabled))
|
||||||
|
|
||||||
|
|
||||||
def list_quick_profiles(namespace: str | None = None) -> list[str]:
|
def list_quick_profiles(namespace: str | None = None) -> list[str]:
|
||||||
"""
|
"""
|
||||||
List only profiles that have quick-select enabled (user wins over default).
|
List only profiles that have quick-select enabled (runtime wins over baseline).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
namespace(str | None, optional): Namespace label. Defaults to ``None``.
|
namespace(str | None, optional): Namespace label. Defaults to ``None``.
|
||||||
@@ -909,8 +950,8 @@ class ProfileInfo(BaseModel):
|
|||||||
is_quick_select: bool = False
|
is_quick_select: bool = False
|
||||||
widget_count: int = 0
|
widget_count: int = 0
|
||||||
size_kb: int = 0
|
size_kb: int = 0
|
||||||
user_path: str = ""
|
runtime_path: str = ""
|
||||||
default_path: str = ""
|
baseline_path: str = ""
|
||||||
origin: ProfileOrigin = "unknown"
|
origin: ProfileOrigin = "unknown"
|
||||||
is_read_only: bool = False
|
is_read_only: bool = False
|
||||||
|
|
||||||
@@ -924,19 +965,19 @@ def get_profile_info(name: str, namespace: str | None = None) -> ProfileInfo:
|
|||||||
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
namespace (str | None, optional): Namespace label. Defaults to ``None``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ProfileInfo: Structured profile metadata, preferring the user copy when present.
|
ProfileInfo: Structured profile metadata, preferring the runtime copy when present.
|
||||||
"""
|
"""
|
||||||
user_paths = user_profile_candidates(name, namespace)
|
runtime_paths = runtime_profile_candidates(name, namespace)
|
||||||
default_paths = default_profile_candidates(name, namespace)
|
baseline_paths = baseline_profile_candidates(name, namespace)
|
||||||
u_path = next((p for p in user_paths if os.path.exists(p)), user_paths[0])
|
r_path = next((p for p in runtime_paths if os.path.exists(p)), runtime_paths[0])
|
||||||
d_path = next((p for p in default_paths if os.path.exists(p)), default_paths[0])
|
b_path = next((p for p in baseline_paths if os.path.exists(p)), baseline_paths[0])
|
||||||
origin = profile_origin(name, namespace)
|
origin = profile_origin(name, namespace)
|
||||||
read_only = origin in {"module", "plugin"}
|
read_only = origin in {"module", "plugin"}
|
||||||
prefer_user = os.path.exists(u_path)
|
prefer_runtime = os.path.exists(r_path)
|
||||||
if prefer_user:
|
if prefer_runtime:
|
||||||
s = QSettings(u_path, QSettings.IniFormat)
|
s = QSettings(r_path, QSettings.IniFormat)
|
||||||
elif os.path.exists(d_path):
|
elif os.path.exists(b_path):
|
||||||
s = QSettings(d_path, QSettings.IniFormat)
|
s = QSettings(b_path, QSettings.IniFormat)
|
||||||
else:
|
else:
|
||||||
s = None
|
s = None
|
||||||
if s is None:
|
if s is None:
|
||||||
@@ -957,14 +998,14 @@ def get_profile_info(name: str, namespace: str | None = None) -> ProfileInfo:
|
|||||||
is_quick_select=False,
|
is_quick_select=False,
|
||||||
widget_count=0,
|
widget_count=0,
|
||||||
size_kb=0,
|
size_kb=0,
|
||||||
user_path=u_path,
|
runtime_path=r_path,
|
||||||
default_path=d_path,
|
baseline_path=b_path,
|
||||||
origin=origin,
|
origin=origin,
|
||||||
is_read_only=read_only,
|
is_read_only=read_only,
|
||||||
)
|
)
|
||||||
|
|
||||||
created = s.value(SETTINGS_KEYS["created_at"], "", type=str) or now_iso_utc()
|
created = s.value(SETTINGS_KEYS["created_at"], "", type=str) or now_iso_utc()
|
||||||
src_path = u_path if prefer_user else d_path
|
src_path = r_path if prefer_runtime else b_path
|
||||||
modified = _file_modified_iso(src_path)
|
modified = _file_modified_iso(src_path)
|
||||||
count = _manifest_count(s)
|
count = _manifest_count(s)
|
||||||
try:
|
try:
|
||||||
@@ -990,8 +1031,8 @@ def get_profile_info(name: str, namespace: str | None = None) -> ProfileInfo:
|
|||||||
is_quick_select=is_quick_select(name, namespace),
|
is_quick_select=is_quick_select(name, namespace),
|
||||||
widget_count=count,
|
widget_count=count,
|
||||||
size_kb=size_kb,
|
size_kb=size_kb,
|
||||||
user_path=u_path,
|
runtime_path=r_path,
|
||||||
default_path=d_path,
|
baseline_path=b_path,
|
||||||
origin=origin,
|
origin=origin,
|
||||||
is_read_only=read_only,
|
is_read_only=read_only,
|
||||||
)
|
)
|
||||||
@@ -999,7 +1040,7 @@ def get_profile_info(name: str, namespace: str | None = None) -> ProfileInfo:
|
|||||||
|
|
||||||
def load_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None:
|
def load_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None:
|
||||||
"""
|
"""
|
||||||
Load the stored screenshot pixmap for a profile from settings (user preferred).
|
Load the stored screenshot pixmap for a profile from settings (runtime preferred).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): Profile name without extension.
|
name (str): Profile name without extension.
|
||||||
@@ -1008,17 +1049,17 @@ def load_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap
|
|||||||
Returns:
|
Returns:
|
||||||
QPixmap | None: Screenshot pixmap or ``None`` if unavailable.
|
QPixmap | None: Screenshot pixmap or ``None`` if unavailable.
|
||||||
"""
|
"""
|
||||||
s = _existing_user_settings(name, namespace)
|
s = _existing_runtime_settings(name, namespace)
|
||||||
if s is None:
|
if s is None:
|
||||||
s = _existing_default_settings(name, namespace)
|
s = _existing_baseline_settings(name, namespace)
|
||||||
if s is None:
|
if s is None:
|
||||||
return None
|
return None
|
||||||
return _load_screenshot_from_settings(s)
|
return _load_screenshot_from_settings(s)
|
||||||
|
|
||||||
|
|
||||||
def load_default_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None:
|
def load_baseline_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None:
|
||||||
"""
|
"""
|
||||||
Load the screenshot from the default profile copy, if available.
|
Load the screenshot from the baseline profile copy, if available.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): Profile name without extension.
|
name (str): Profile name without extension.
|
||||||
@@ -1027,15 +1068,15 @@ def load_default_profile_screenshot(name: str, namespace: str | None = None) ->
|
|||||||
Returns:
|
Returns:
|
||||||
QPixmap | None: Screenshot pixmap or ``None`` if unavailable.
|
QPixmap | None: Screenshot pixmap or ``None`` if unavailable.
|
||||||
"""
|
"""
|
||||||
s = _existing_default_settings(name, namespace)
|
s = _existing_baseline_settings(name, namespace)
|
||||||
if s is None:
|
if s is None:
|
||||||
return None
|
return None
|
||||||
return _load_screenshot_from_settings(s)
|
return _load_screenshot_from_settings(s)
|
||||||
|
|
||||||
|
|
||||||
def load_user_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None:
|
def load_runtime_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None:
|
||||||
"""
|
"""
|
||||||
Load the screenshot from the user profile copy, if available.
|
Load the screenshot from the runtime profile copy, if available.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): Profile name without extension.
|
name (str): Profile name without extension.
|
||||||
@@ -1044,7 +1085,7 @@ def load_user_profile_screenshot(name: str, namespace: str | None = None) -> QPi
|
|||||||
Returns:
|
Returns:
|
||||||
QPixmap | None: Screenshot pixmap or ``None`` if unavailable.
|
QPixmap | None: Screenshot pixmap or ``None`` if unavailable.
|
||||||
"""
|
"""
|
||||||
s = _existing_user_settings(name, namespace)
|
s = _existing_runtime_settings(name, namespace)
|
||||||
if s is None:
|
if s is None:
|
||||||
return None
|
return None
|
||||||
return _load_screenshot_from_settings(s)
|
return _load_screenshot_from_settings(s)
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ class SaveProfileDialog(QDialog):
|
|||||||
self,
|
self,
|
||||||
"Read-only profile",
|
"Read-only profile",
|
||||||
(
|
(
|
||||||
f"'{name}' is a default profile provided by {provider} and cannot be overwritten.\n"
|
f"'{name}' is a baseline profile provided by {provider} and cannot be overwritten.\n"
|
||||||
"Please choose a different name."
|
"Please choose a different name."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -179,7 +179,7 @@ class SaveProfileDialog(QDialog):
|
|||||||
"Overwrite profile",
|
"Overwrite profile",
|
||||||
(
|
(
|
||||||
f"A profile named '{name}' already exists.\n\n"
|
f"A profile named '{name}' already exists.\n\n"
|
||||||
"Overwriting will update both the saved profile and its restore default.\n"
|
"Overwriting will update both the runtime profile and its restore baseline.\n"
|
||||||
"Do you want to continue?"
|
"Do you want to continue?"
|
||||||
),
|
),
|
||||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
@@ -257,21 +257,24 @@ class PreviewPanel(QGroupBox):
|
|||||||
|
|
||||||
class RestoreProfileDialog(QDialog):
|
class RestoreProfileDialog(QDialog):
|
||||||
"""
|
"""
|
||||||
Confirmation dialog that previews the current profile screenshot against the default baseline.
|
Confirmation dialog that previews the current runtime screenshot against the baseline.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, parent: QWidget | None, current_pixmap: QPixmap | None, default_pixmap: QPixmap | None
|
self,
|
||||||
|
parent: QWidget | None,
|
||||||
|
current_pixmap: QPixmap | None,
|
||||||
|
baseline_pixmap: QPixmap | None,
|
||||||
):
|
):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Restore Profile to Default")
|
self.setWindowTitle("Restore Profile to Baseline")
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.resize(880, 480)
|
self.resize(880, 480)
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
info_label = QLabel(
|
info_label = QLabel(
|
||||||
"Restoring will discard your custom layout and replace it with the default profile."
|
"Restoring will discard your runtime layout and replace it with the baseline profile."
|
||||||
)
|
)
|
||||||
info_label.setWordWrap(True)
|
info_label.setWordWrap(True)
|
||||||
layout.addWidget(info_label)
|
layout.addWidget(info_label)
|
||||||
@@ -280,7 +283,7 @@ class RestoreProfileDialog(QDialog):
|
|||||||
layout.addLayout(preview_row)
|
layout.addLayout(preview_row)
|
||||||
|
|
||||||
current_preview = PreviewPanel("Current", current_pixmap, self)
|
current_preview = PreviewPanel("Current", current_pixmap, self)
|
||||||
default_preview = PreviewPanel("Default", default_pixmap, self)
|
baseline_preview = PreviewPanel("Baseline", baseline_pixmap, self)
|
||||||
|
|
||||||
# Equal expansion left/right
|
# Equal expansion left/right
|
||||||
preview_row.addWidget(current_preview, 1)
|
preview_row.addWidget(current_preview, 1)
|
||||||
@@ -292,7 +295,7 @@ class RestoreProfileDialog(QDialog):
|
|||||||
arrow_label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
|
arrow_label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
|
||||||
preview_row.addWidget(arrow_label)
|
preview_row.addWidget(arrow_label)
|
||||||
|
|
||||||
preview_row.addWidget(default_preview, 1)
|
preview_row.addWidget(baseline_preview, 1)
|
||||||
|
|
||||||
# Enforce equal stretch for both previews
|
# Enforce equal stretch for both previews
|
||||||
preview_row.setStretch(0, 1)
|
preview_row.setStretch(0, 1)
|
||||||
@@ -300,7 +303,7 @@ class RestoreProfileDialog(QDialog):
|
|||||||
preview_row.setStretch(2, 1)
|
preview_row.setStretch(2, 1)
|
||||||
|
|
||||||
warn_label = QLabel(
|
warn_label = QLabel(
|
||||||
"This action cannot be undone. Do you want to restore the default layout now?"
|
"This action cannot be undone. Do you want to restore the baseline layout now?"
|
||||||
)
|
)
|
||||||
warn_label.setWordWrap(True)
|
warn_label.setWordWrap(True)
|
||||||
layout.addWidget(warn_label)
|
layout.addWidget(warn_label)
|
||||||
@@ -324,7 +327,7 @@ class RestoreProfileDialog(QDialog):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def confirm(
|
def confirm(
|
||||||
parent: QWidget | None, current_pixmap: QPixmap | None, default_pixmap: QPixmap | None
|
parent: QWidget | None, current_pixmap: QPixmap | None, baseline_pixmap: QPixmap | None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
dialog = RestoreProfileDialog(parent, current_pixmap, default_pixmap)
|
dialog = RestoreProfileDialog(parent, current_pixmap, baseline_pixmap)
|
||||||
return dialog.exec() == QDialog.Accepted
|
return dialog.exec() == QDialog.Accepted
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class WorkSpaceManager(BECWidget, QWidget):
|
|||||||
HEADERS = ["Actions", "Profile", "Author"]
|
HEADERS = ["Actions", "Profile", "Author"]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, parent=None, target_widget=None, default_profile: str | None = None, **kwargs
|
self, parent=None, target_widget=None, active_profile: str | None = None, **kwargs
|
||||||
):
|
):
|
||||||
super().__init__(parent=parent, **kwargs)
|
super().__init__(parent=parent, **kwargs)
|
||||||
self.target_widget = target_widget
|
self.target_widget = target_widget
|
||||||
@@ -59,13 +59,13 @@ class WorkSpaceManager(BECWidget, QWidget):
|
|||||||
self._init_ui()
|
self._init_ui()
|
||||||
if self.target_widget is not None and hasattr(self.target_widget, "profile_changed"):
|
if self.target_widget is not None and hasattr(self.target_widget, "profile_changed"):
|
||||||
self.target_widget.profile_changed.connect(self.on_profile_changed)
|
self.target_widget.profile_changed.connect(self.on_profile_changed)
|
||||||
if default_profile is not None:
|
if active_profile is not None:
|
||||||
self._select_by_name(default_profile)
|
self._select_by_name(active_profile)
|
||||||
self._show_profile_details(default_profile)
|
self._show_profile_details(active_profile)
|
||||||
|
|
||||||
def _init_ui(self):
|
def _init_ui(self):
|
||||||
self.root_layout = QHBoxLayout(self)
|
self.root_layout = QHBoxLayout(self)
|
||||||
self.splitter = QSplitter(Qt.Horizontal, self)
|
self.splitter = QSplitter(Qt.Orientation.Horizontal, self)
|
||||||
self.root_layout.addWidget(self.splitter)
|
self.root_layout.addWidget(self.splitter)
|
||||||
|
|
||||||
# Init components
|
# Init components
|
||||||
@@ -89,7 +89,9 @@ class WorkSpaceManager(BECWidget, QWidget):
|
|||||||
left_panel.setMinimumWidth(220)
|
left_panel.setMinimumWidth(220)
|
||||||
|
|
||||||
# Make the screenshot preview expand to fill remaining space
|
# Make the screenshot preview expand to fill remaining space
|
||||||
self.screenshot_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
self.screenshot_label.setSizePolicy(
|
||||||
|
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
|
||||||
|
)
|
||||||
|
|
||||||
self.right_box = QGroupBox("Profile Screenshot Preview", self)
|
self.right_box = QGroupBox("Profile Screenshot Preview", self)
|
||||||
right_col = QVBoxLayout(self.right_box)
|
right_col = QVBoxLayout(self.right_box)
|
||||||
@@ -250,8 +252,8 @@ class WorkSpaceManager(BECWidget, QWidget):
|
|||||||
("Quick select", "Yes" if info.is_quick_select else "No"),
|
("Quick select", "Yes" if info.is_quick_select else "No"),
|
||||||
("Widgets", str(info.widget_count)),
|
("Widgets", str(info.widget_count)),
|
||||||
("Size (KB)", str(info.size_kb)),
|
("Size (KB)", str(info.size_kb)),
|
||||||
("User path", info.user_path or ""),
|
("Runtime path", info.runtime_path or ""),
|
||||||
("Default path", info.default_path or ""),
|
("Baseline path", info.baseline_path or ""),
|
||||||
]
|
]
|
||||||
for k, v in entries:
|
for k, v in entries:
|
||||||
self.profile_details_tree.addTopLevelItem(QTreeWidgetItem([k, v]))
|
self.profile_details_tree.addTopLevelItem(QTreeWidgetItem([k, v]))
|
||||||
|
|||||||
@@ -24,19 +24,9 @@ class ProfileComboBox(QComboBox):
|
|||||||
def set_quick_profile_provider(self, provider: Callable[[], list[str]]) -> None:
|
def set_quick_profile_provider(self, provider: Callable[[], list[str]]) -> None:
|
||||||
self._quick_provider = provider
|
self._quick_provider = provider
|
||||||
|
|
||||||
def refresh_profiles(
|
def _refresh_profiles(
|
||||||
self, active_profile: str | None = None, show_empty_profile: bool = False
|
self, current_text: str, active_profile: str | None = None, show_empty_profile: bool = False
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
|
||||||
Refresh the profile list and ensure the active profile is visible.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
active_profile(str | None): The currently active profile name.
|
|
||||||
show_empty_profile(bool): If True, show an explicit empty unsaved workspace entry.
|
|
||||||
"""
|
|
||||||
|
|
||||||
current_text = active_profile or self.currentText()
|
|
||||||
self.blockSignals(True)
|
|
||||||
self.clear()
|
self.clear()
|
||||||
|
|
||||||
quick_profiles = self._quick_provider()
|
quick_profiles = self._quick_provider()
|
||||||
@@ -103,7 +93,6 @@ class ProfileComboBox(QComboBox):
|
|||||||
if index >= 0:
|
if index >= 0:
|
||||||
self.setCurrentIndex(index)
|
self.setCurrentIndex(index)
|
||||||
|
|
||||||
self.blockSignals(False)
|
|
||||||
if active_profile and self.currentText() != active_profile:
|
if active_profile and self.currentText() != active_profile:
|
||||||
idx = self.findText(active_profile)
|
idx = self.findText(active_profile)
|
||||||
if idx >= 0:
|
if idx >= 0:
|
||||||
@@ -115,6 +104,24 @@ class ProfileComboBox(QComboBox):
|
|||||||
else:
|
else:
|
||||||
self.setToolTip("")
|
self.setToolTip("")
|
||||||
|
|
||||||
|
def refresh_profiles(
|
||||||
|
self, active_profile: str | None = None, show_empty_profile: bool = False
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Refresh the profile list and ensure the active profile is visible.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
active_profile(str | None): The currently active profile name.
|
||||||
|
show_empty_profile(bool): If True, show an explicit empty unsaved workspace entry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
current_text = active_profile or self.currentText()
|
||||||
|
was_blocked = self.blockSignals(True)
|
||||||
|
try:
|
||||||
|
self._refresh_profiles(current_text, active_profile, show_empty_profile)
|
||||||
|
finally:
|
||||||
|
self.blockSignals(was_blocked)
|
||||||
|
|
||||||
|
|
||||||
def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -> ToolbarBundle:
|
def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -> ToolbarBundle:
|
||||||
"""
|
"""
|
||||||
@@ -122,6 +129,7 @@ def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
components (ToolbarComponents): The components to be added to the bundle.
|
components (ToolbarComponents): The components to be added to the bundle.
|
||||||
|
enable_tools(bool): If True, show the workspace management tools; otherwise, only show the profile combo.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ToolbarBundle: The workspace toolbar bundle.
|
ToolbarBundle: The workspace toolbar bundle.
|
||||||
@@ -143,15 +151,15 @@ def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -
|
|||||||
components.get_action("save_workspace").action.setVisible(enable_tools)
|
components.get_action("save_workspace").action.setVisible(enable_tools)
|
||||||
|
|
||||||
components.add_safe(
|
components.add_safe(
|
||||||
"reset_default_workspace",
|
"reset_baseline_workspace",
|
||||||
MaterialIconAction(
|
MaterialIconAction(
|
||||||
icon_name="undo",
|
icon_name="undo",
|
||||||
tooltip="Refresh Current Workspace",
|
tooltip="Restore Baseline Profile",
|
||||||
checkable=False,
|
checkable=False,
|
||||||
parent=components.toolbar,
|
parent=components.toolbar,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
components.get_action("reset_default_workspace").action.setVisible(enable_tools)
|
components.get_action("reset_baseline_workspace").action.setVisible(enable_tools)
|
||||||
|
|
||||||
components.add_safe(
|
components.add_safe(
|
||||||
"manage_workspaces",
|
"manage_workspaces",
|
||||||
@@ -164,7 +172,7 @@ def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -
|
|||||||
bundle = ToolbarBundle("workspace", components)
|
bundle = ToolbarBundle("workspace", components)
|
||||||
bundle.add_action("workspace_combo")
|
bundle.add_action("workspace_combo")
|
||||||
bundle.add_action("save_workspace")
|
bundle.add_action("save_workspace")
|
||||||
bundle.add_action("reset_default_workspace")
|
bundle.add_action("reset_baseline_workspace")
|
||||||
bundle.add_action("manage_workspaces")
|
bundle.add_action("manage_workspaces")
|
||||||
return bundle
|
return bundle
|
||||||
|
|
||||||
@@ -194,9 +202,9 @@ class WorkspaceConnection(BundleConnection):
|
|||||||
self.target_widget.load_profile
|
self.target_widget.load_profile
|
||||||
)
|
)
|
||||||
|
|
||||||
reset_action = self.components.get_action("reset_default_workspace").action
|
reset_action = self.components.get_action("reset_baseline_workspace").action
|
||||||
if reset_action.isVisible():
|
if reset_action.isVisible():
|
||||||
reset_action.triggered.connect(self._reset_workspace_to_default)
|
reset_action.triggered.connect(self._reset_workspace_to_baseline)
|
||||||
|
|
||||||
manage_action = self.components.get_action("manage_workspaces").action
|
manage_action = self.components.get_action("manage_workspaces").action
|
||||||
if manage_action.isVisible():
|
if manage_action.isVisible():
|
||||||
@@ -213,9 +221,9 @@ class WorkspaceConnection(BundleConnection):
|
|||||||
self.target_widget.load_profile
|
self.target_widget.load_profile
|
||||||
)
|
)
|
||||||
|
|
||||||
reset_action = self.components.get_action("reset_default_workspace").action
|
reset_action = self.components.get_action("reset_baseline_workspace").action
|
||||||
if reset_action.isVisible():
|
if reset_action.isVisible():
|
||||||
reset_action.triggered.disconnect(self._reset_workspace_to_default)
|
reset_action.triggered.disconnect(self._reset_workspace_to_baseline)
|
||||||
|
|
||||||
manage_action = self.components.get_action("manage_workspaces").action
|
manage_action = self.components.get_action("manage_workspaces").action
|
||||||
if manage_action.isVisible():
|
if manage_action.isVisible():
|
||||||
@@ -223,8 +231,8 @@ class WorkspaceConnection(BundleConnection):
|
|||||||
self._connected = False
|
self._connected = False
|
||||||
|
|
||||||
@SafeSlot()
|
@SafeSlot()
|
||||||
def _reset_workspace_to_default(self):
|
def _reset_workspace_to_baseline(self):
|
||||||
"""
|
"""
|
||||||
Refreshes the current workspace.
|
Refreshes the current workspace.
|
||||||
"""
|
"""
|
||||||
self.target_widget.restore_user_profile_from_default()
|
self.target_widget.restore_baseline_profile(show_dialog=True)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from qtpy.QtWidgets import (
|
|||||||
)
|
)
|
||||||
from typeguard import typechecked
|
from typeguard import typechecked
|
||||||
|
|
||||||
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
from bec_widgets.utils.rpc_widget_handler import widget_handler
|
||||||
|
|
||||||
|
|
||||||
class LayoutManagerWidget(QWidget):
|
class LayoutManagerWidget(QWidget):
|
||||||
|
|||||||
@@ -1,27 +1,83 @@
|
|||||||
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 QApplication, QHBoxLayout, QLabel, QProgressBar, QVBoxLayout, QWidget
|
from qtpy.QtWidgets import (
|
||||||
|
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__(None, Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
|
super().__init__(
|
||||||
self.setAttribute(Qt.WA_ShowWithoutActivating)
|
None,
|
||||||
|
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(6, 6, 6, 6)
|
layout.setContentsMargins(14, 14, 14, 14)
|
||||||
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()
|
||||||
@@ -30,11 +86,43 @@ 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):
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ from qtpy.QtCore import QObject, QTimer
|
|||||||
from qtpy.QtWidgets import QApplication, QFrame, QMainWindow, QScrollArea, QWidget
|
from qtpy.QtWidgets import QApplication, QFrame, QMainWindow, QScrollArea, QWidget
|
||||||
|
|
||||||
from bec_widgets import SafeProperty, SafeSlot
|
from bec_widgets import SafeProperty, SafeSlot
|
||||||
from bec_widgets.utils import BECConnector
|
from bec_widgets.utils.bec_connector import BECConnector
|
||||||
from bec_widgets.utils.colors import apply_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
from bec_widgets.utils.widget_io import WidgetIO
|
from bec_widgets.utils.widget_io import WidgetIO
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
from .positioner_box_base import PositionerBoxBase
|
|
||||||
|
|
||||||
__ALL__ = ["PositionerBoxBase"]
|
|
||||||
+7
-9
@@ -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.widgets.control.device_control.positioner_box._base import PositionerBoxBase
|
from bec_widgets.utils.ui_loader import UILoader
|
||||||
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
|
from bec_widgets.widgets.control.device_control.positioner_box.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.addWidget(self.ui)
|
self.main_layout.addWidget(self.ui)
|
||||||
self.layout.setSpacing(0)
|
self.main_layout.setSpacing(0)
|
||||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignHCenter)
|
self.main_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,8 +115,6 @@ 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)
|
||||||
|
|||||||
+6
-8
@@ -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.widgets.control.device_control.positioner_box._base import PositionerBoxBase
|
from bec_widgets.utils.ui_loader import UILoader
|
||||||
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
|
from bec_widgets.widgets.control.device_control.positioner_box.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.addWidget(self.ui)
|
self.main_layout.addWidget(self.ui)
|
||||||
self.layout.setSpacing(0)
|
self.main_layout.setSpacing(0)
|
||||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
self.main_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,7 +200,6 @@ 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)
|
||||||
|
|
||||||
@@ -220,7 +219,6 @@ 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)
|
||||||
|
|
||||||
|
|||||||
+11
-10
@@ -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, CompactPopupWidget):
|
class PositionerBoxBase(BECWidget, QWidget):
|
||||||
"""Contains some core logic for positioner box widgets"""
|
"""Contains some core logic for positioner box widgets"""
|
||||||
|
|
||||||
current_path = ""
|
current_path = ""
|
||||||
@@ -57,7 +57,10 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
|
|||||||
parent: The parent widget.
|
parent: The parent widget.
|
||||||
device (Positioner): The device to control.
|
device (Positioner): The device to control.
|
||||||
"""
|
"""
|
||||||
super().__init__(parent=parent, layout=QVBoxLayout, **kwargs)
|
super().__init__(parent=parent, **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()
|
||||||
|
|
||||||
@@ -173,11 +176,9 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
|
|||||||
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)
|
||||||
|
|
||||||
@@ -196,9 +197,8 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
|
|||||||
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)
|
||||||
|
|
||||||
def _update_limits_ui(
|
@staticmethod
|
||||||
self, limits: tuple[float, float], position_indicator, setpoint_validator
|
def _update_limits_ui(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,8 +223,9 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
|
|||||||
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))
|
||||||
|
|
||||||
def _toggle_enable_buttons(self, ui: DeviceUpdateUIComponents, enable: bool) -> None:
|
@staticmethod
|
||||||
"""Toogle enable/disable on available buttons
|
def _toggle_enable_buttons(ui: DeviceUpdateUIComponents, enable: bool) -> None:
|
||||||
|
"""Toggle enable/disable on available buttons
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
enable (bool): Enable buttons
|
enable (bool): Enable buttons
|
||||||
+77
@@ -1,6 +1,8 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -22,7 +24,82 @@ 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
|
||||||
|
|||||||
+31
-4
@@ -2,12 +2,18 @@
|
|||||||
<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>612</width>
|
<width>592</width>
|
||||||
<height>91</height>
|
<height>76</height>
|
||||||
</rect>
|
</rect>
|
||||||
</property>
|
</property>
|
||||||
<property name="minimumSize">
|
<property name="minimumSize">
|
||||||
@@ -26,8 +32,29 @@
|
|||||||
<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>
|
||||||
@@ -227,12 +254,12 @@
|
|||||||
<customwidgets>
|
<customwidgets>
|
||||||
<customwidget>
|
<customwidget>
|
||||||
<class>PositionIndicator</class>
|
<class>PositionIndicator</class>
|
||||||
<extends>QWidget</extends>
|
<extends></extends>
|
||||||
<header>position_indicator</header>
|
<header>position_indicator</header>
|
||||||
</customwidget>
|
</customwidget>
|
||||||
<customwidget>
|
<customwidget>
|
||||||
<class>SpinnerWidget</class>
|
<class>SpinnerWidget</class>
|
||||||
<extends>QWidget</extends>
|
<extends></extends>
|
||||||
<header>spinner_widget</header>
|
<header>spinner_widget</header>
|
||||||
</customwidget>
|
</customwidget>
|
||||||
</customwidgets>
|
</customwidgets>
|
||||||
|
|||||||
@@ -27,30 +27,13 @@ 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 import ConnectionConfig
|
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
from bec_widgets.utils.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 import ConnectionConfig
|
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
from bec_widgets.utils.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
|
||||||
|
|||||||
+2
-2
@@ -13,7 +13,7 @@ if TYPE_CHECKING:
|
|||||||
from .available_device_group import AvailableDeviceGroup
|
from .available_device_group import AvailableDeviceGroup
|
||||||
|
|
||||||
|
|
||||||
class _DeviceListWiget(QListWidget):
|
class _DeviceListWidget(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 = _DeviceListWiget(AvailableDeviceGroup)
|
self.device_list = _DeviceListWidget(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 availble devices separated by tag groups. The same device may
|
"""A dictionary of all available 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 availble devices. The same device may not appear more than once."""
|
"""A set of all available devices. The same device may not appear more than once."""
|
||||||
...
|
...
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from qtpy.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from bec_widgets.utils import ConnectionConfig
|
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||||
from bec_widgets.utils.bec_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
|
||||||
|
|||||||
@@ -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 formated to run scan from BEC.
|
Returns the parameters from the widgets in the scan control layout formatted to run scan from BEC.
|
||||||
"""
|
"""
|
||||||
if self.box_type == "args":
|
if self.box_type == "args":
|
||||||
return self._get_arg_parameterts(device_object=device_object)
|
return self._get_arg_parameters(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_parameterts(self, device_object: bool = True):
|
def _get_arg_parameters(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,22 +2,19 @@
|
|||||||
|
|
||||||
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, QVBoxLayout, QWidget
|
from qtpy.QtWidgets import QComboBox
|
||||||
|
|
||||||
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, QWidget):
|
class DapComboBox(BECWidget, QComboBox):
|
||||||
"""
|
"""
|
||||||
The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC.
|
Editable combobox listing the available DAP models.
|
||||||
|
|
||||||
Args:
|
The widget behaves as a plain QComboBox and keeps ``fit_model_combobox`` as an alias to itself
|
||||||
parent: Parent widget.
|
for backwards compatibility with older call sites.
|
||||||
client: BEC client object.
|
|
||||||
gui_id: GUI ID.
|
|
||||||
default: Default device name.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ICON_NAME = "data_exploration"
|
ICON_NAME = "data_exploration"
|
||||||
@@ -45,19 +42,20 @@ class DapComboBox(BECWidget, QWidget):
|
|||||||
**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.layout = QVBoxLayout(self)
|
self.fit_model_combobox = self # Just for backwards compatibility with older call sites, the widget itself is the combobox
|
||||||
self.fit_model_combobox = QComboBox(self)
|
self._available_models: list[str] = []
|
||||||
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.populate_fit_model_combobox()
|
self._is_valid_input = False
|
||||||
self.fit_model_combobox.currentTextChanged.connect(self._update_current_fit)
|
|
||||||
# Set default fit model
|
|
||||||
self.select_default_fit(default_fit)
|
|
||||||
|
|
||||||
def select_default_fit(self, default_fit: str | None):
|
self.setEditable(True)
|
||||||
|
|
||||||
|
self.populate_fit_model_combobox()
|
||||||
|
self.currentTextChanged.connect(self._on_text_changed)
|
||||||
|
self.select_default_fit(default_fit)
|
||||||
|
self.check_validity(self.currentText())
|
||||||
|
|
||||||
|
def select_default_fit(self, default_fit: str | None = "GaussianModel"):
|
||||||
"""Set the default fit model.
|
"""Set the default fit model.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -65,8 +63,8 @@ class DapComboBox(BECWidget, QWidget):
|
|||||||
"""
|
"""
|
||||||
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)
|
||||||
else:
|
elif self.available_models:
|
||||||
self.select_fit_model("GaussianModel")
|
self.select_fit_model(self.available_models[0])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available_models(self):
|
def available_models(self):
|
||||||
@@ -114,12 +112,40 @@ class DapComboBox(BECWidget, QWidget):
|
|||||||
self._y_axis = y_axis
|
self._y_axis = y_axis
|
||||||
self.y_axis_updated.emit(y_axis)
|
self.y_axis_updated.emit(y_axis)
|
||||||
|
|
||||||
def _update_current_fit(self, fit_name: str):
|
@Slot(str)
|
||||||
"""Update the current fit."""
|
def _on_text_changed(self, fit_name: str):
|
||||||
|
"""
|
||||||
|
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.
|
||||||
@@ -128,7 +154,7 @@ class DapComboBox(BECWidget, QWidget):
|
|||||||
x_axis(str): X axis.
|
x_axis(str): X axis.
|
||||||
"""
|
"""
|
||||||
self.x_axis = x_axis
|
self.x_axis = x_axis
|
||||||
self._update_current_fit(self.fit_model_combobox.currentText())
|
self._on_text_changed(self.currentText())
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
def select_y_axis(self, y_axis: str):
|
def select_y_axis(self, y_axis: str):
|
||||||
@@ -138,25 +164,26 @@ class DapComboBox(BECWidget, QWidget):
|
|||||||
y_axis(str): Y axis.
|
y_axis(str): Y axis.
|
||||||
"""
|
"""
|
||||||
self.y_axis = y_axis
|
self.y_axis = y_axis
|
||||||
self._update_current_fit(self.fit_model_combobox.currentText())
|
self._on_text_changed(self.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:
|
||||||
default_device(str): Default device name.
|
fit_name(str): Fit model 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.fit_model_combobox.setCurrentText(fit_name)
|
self.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
|
||||||
self.available_models = [model for model in self.client.dap._available_dap_plugins.keys()]
|
available_plugins = getattr(getattr(self.client, "dap", None), "_available_dap_plugins", {})
|
||||||
self.fit_model_combobox.clear()
|
self.available_models = [model for model in available_plugins.keys()]
|
||||||
self.fit_model_combobox.addItems(self.available_models)
|
self.clear()
|
||||||
|
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.
|
||||||
@@ -166,23 +193,23 @@ class DapComboBox(BECWidget, QWidget):
|
|||||||
"""
|
"""
|
||||||
if model is None:
|
if model is None:
|
||||||
return False
|
return False
|
||||||
if model not in self.available_models:
|
return model in self.available_models
|
||||||
return False
|
|
||||||
return True
|
@property
|
||||||
|
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
|
||||||
# pylint: disable=import-outside-toplevel
|
import sys
|
||||||
|
|
||||||
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([])
|
app = QApplication(sys.argv)
|
||||||
apply_theme("dark")
|
apply_theme("dark")
|
||||||
widget = QWidget()
|
dialog = DapComboBox()
|
||||||
widget.setFixedSize(200, 200)
|
dialog.show()
|
||||||
layout = QVBoxLayout()
|
sys.exit(app.exec_())
|
||||||
widget.setLayout(layout)
|
|
||||||
layout.addWidget(DapComboBox())
|
|
||||||
widget.show()
|
|
||||||
app.exec_()
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
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, QTreeWidgetItem, QVBoxLayout, QWidget
|
from qtpy.QtWidgets import QPushButton, QSizePolicy, QTreeWidgetItem, QVBoxLayout, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils import UILoader
|
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
from bec_widgets.utils.colors import get_accent_colors
|
from bec_widgets.utils.colors import get_accent_colors
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
|
from bec_widgets.utils.ui_loader import UILoader
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ class LMFitDialog(BECWidget, QWidget):
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialises the LMFitDialog widget.
|
Initializes the LMFitDialog widget.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
parent (QWidget): The parent widget.
|
parent (QWidget): The parent widget.
|
||||||
@@ -68,6 +69,27 @@ 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:
|
||||||
@@ -77,8 +99,14 @@ 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
|
||||||
for button in self.action_buttons.values():
|
valid_buttons = {}
|
||||||
|
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]:
|
||||||
@@ -89,16 +117,6 @@ 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."""
|
||||||
@@ -154,19 +172,21 @@ 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:
|
def fit_curve_id(self) -> str | None:
|
||||||
"""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):
|
def fit_curve_id(self, curve_id: str | None):
|
||||||
"""Setter for the currently displayed fit curve_id.
|
"""Setter for the currently displayed fit curve_id.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
fit_curve_id (str): The curve_id of the fit curve to be displayed.
|
curve_id (str | None): 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
|
||||||
self.selected_fit.emit(curve_id)
|
if curve_id is not None:
|
||||||
|
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):
|
||||||
@@ -176,6 +196,15 @@ 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)
|
||||||
@@ -251,6 +280,7 @@ 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]
|
||||||
@@ -269,9 +299,9 @@ 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(f"Move to {param_name}")
|
button = QPushButton("Move")
|
||||||
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 is True:
|
if self.enable_actions:
|
||||||
button.setEnabled(True)
|
button.setEnabled(True)
|
||||||
else:
|
else:
|
||||||
button.setEnabled(False)
|
button.setEnabled(False)
|
||||||
|
|||||||
@@ -14,6 +14,18 @@
|
|||||||
<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">
|
||||||
@@ -22,15 +34,6 @@
|
|||||||
<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>
|
||||||
@@ -41,6 +44,12 @@
|
|||||||
<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>
|
||||||
@@ -58,18 +67,36 @@
|
|||||||
</sizepolicy>
|
</sizepolicy>
|
||||||
</property>
|
</property>
|
||||||
<property name="orientation">
|
<property name="orientation">
|
||||||
<enum>Qt::Orientation::Vertical</enum>
|
<enum>Qt::Orientation::Horizontal</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>
|
||||||
@@ -85,12 +112,33 @@
|
|||||||
</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>
|
||||||
@@ -106,6 +154,11 @@
|
|||||||
<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,6 +95,12 @@
|
|||||||
<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>
|
||||||
@@ -147,6 +153,12 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -0,0 +1,605 @@
|
|||||||
|
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_())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{'files': ['bec_console.py']}
|
||||||
+9
-9
@@ -5,17 +5,17 @@ from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
|||||||
from qtpy.QtWidgets import QWidget
|
from qtpy.QtWidgets import QWidget
|
||||||
|
|
||||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||||
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
|
from bec_widgets.widgets.editors.bec_console.bec_console import BecConsole
|
||||||
|
|
||||||
DOM_XML = """
|
DOM_XML = """
|
||||||
<ui language='c++'>
|
<ui language='c++'>
|
||||||
<widget class='WebConsole' name='web_console'>
|
<widget class='BecConsole' name='bec_console'>
|
||||||
</widget>
|
</widget>
|
||||||
</ui>
|
</ui>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class WebConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
class BecConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._form_editor = None
|
self._form_editor = None
|
||||||
@@ -23,20 +23,20 @@ class WebConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
|||||||
def createWidget(self, parent):
|
def createWidget(self, parent):
|
||||||
if parent is None:
|
if parent is None:
|
||||||
return QWidget()
|
return QWidget()
|
||||||
t = WebConsole(parent)
|
t = BecConsole(parent)
|
||||||
return t
|
return t
|
||||||
|
|
||||||
def domXml(self):
|
def domXml(self):
|
||||||
return DOM_XML
|
return DOM_XML
|
||||||
|
|
||||||
def group(self):
|
def group(self):
|
||||||
return "BEC Developer"
|
return ""
|
||||||
|
|
||||||
def icon(self):
|
def icon(self):
|
||||||
return designer_material_icon(WebConsole.ICON_NAME)
|
return designer_material_icon(BecConsole.ICON_NAME)
|
||||||
|
|
||||||
def includeFile(self):
|
def includeFile(self):
|
||||||
return "web_console"
|
return "bec_console"
|
||||||
|
|
||||||
def initialize(self, form_editor):
|
def initialize(self, form_editor):
|
||||||
self._form_editor = form_editor
|
self._form_editor = form_editor
|
||||||
@@ -48,10 +48,10 @@ class WebConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
|||||||
return self._form_editor is not None
|
return self._form_editor is not None
|
||||||
|
|
||||||
def name(self):
|
def name(self):
|
||||||
return "WebConsole"
|
return "BecConsole"
|
||||||
|
|
||||||
def toolTip(self):
|
def toolTip(self):
|
||||||
return ""
|
return "A console widget with access to a shared registry of terminals, such that instances can be moved around."
|
||||||
|
|
||||||
def whatsThis(self):
|
def whatsThis(self):
|
||||||
return self.toolTip()
|
return self.toolTip()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{'files': ['bec_console.py']}
|
||||||
+1
-1
@@ -5,7 +5,7 @@ from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
|||||||
from qtpy.QtWidgets import QWidget
|
from qtpy.QtWidgets import QWidget
|
||||||
|
|
||||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||||
from bec_widgets.widgets.editors.web_console.web_console import BECShell
|
from bec_widgets.widgets.editors.bec_console.bec_console import BECShell
|
||||||
|
|
||||||
DOM_XML = """
|
DOM_XML = """
|
||||||
<ui language='c++'>
|
<ui language='c++'>
|
||||||
+2
-2
@@ -6,9 +6,9 @@ def main(): # pragma: no cover
|
|||||||
return
|
return
|
||||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||||
|
|
||||||
from bec_widgets.widgets.editors.web_console.web_console_plugin import WebConsolePlugin
|
from bec_widgets.widgets.editors.bec_console.bec_console_plugin import BecConsolePlugin
|
||||||
|
|
||||||
QPyDesignerCustomWidgetCollection.addCustomWidget(WebConsolePlugin())
|
QPyDesignerCustomWidgetCollection.addCustomWidget(BecConsolePlugin())
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
+1
-1
@@ -6,7 +6,7 @@ def main(): # pragma: no cover
|
|||||||
return
|
return
|
||||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||||
|
|
||||||
from bec_widgets.widgets.editors.web_console.bec_shell_plugin import BECShellPlugin
|
from bec_widgets.widgets.editors.bec_console.bec_shell_plugin import BECShellPlugin
|
||||||
|
|
||||||
QPyDesignerCustomWidgetCollection.addCustomWidget(BECShellPlugin())
|
QPyDesignerCustomWidgetCollection.addCustomWidget(BECShellPlugin())
|
||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{'files': ['web_console.py']}
|
|
||||||
@@ -1,705 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import enum
|
|
||||||
import json
|
|
||||||
import secrets
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
|
|
||||||
from bec_lib.logger import bec_logger
|
|
||||||
from louie.saferef import safe_ref
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from qtpy.QtCore import Qt, QTimer, QUrl, Signal, qInstallMessageHandler
|
|
||||||
from qtpy.QtGui import QMouseEvent, QResizeEvent
|
|
||||||
from qtpy.QtWebEngineWidgets import QWebEnginePage, QWebEngineView
|
|
||||||
from qtpy.QtWidgets import QApplication, QLabel, QTabWidget, QVBoxLayout, QWidget
|
|
||||||
|
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
|
||||||
|
|
||||||
logger = bec_logger.logger
|
|
||||||
|
|
||||||
|
|
||||||
class ConsoleMode(str, enum.Enum):
|
|
||||||
ACTIVE = "active"
|
|
||||||
INACTIVE = "inactive"
|
|
||||||
HIDDEN = "hidden"
|
|
||||||
|
|
||||||
|
|
||||||
class PageOwnerInfo(BaseModel):
|
|
||||||
owner_gui_id: str | None = None
|
|
||||||
widget_ids: list[str] = []
|
|
||||||
page: QWebEnginePage | None = None
|
|
||||||
initialized: bool = False
|
|
||||||
|
|
||||||
model_config = {"arbitrary_types_allowed": True}
|
|
||||||
|
|
||||||
|
|
||||||
class WebConsoleRegistry:
|
|
||||||
"""
|
|
||||||
A registry for the WebConsole class to manage its instances.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""
|
|
||||||
Initialize the registry.
|
|
||||||
"""
|
|
||||||
self._instances = {}
|
|
||||||
self._server_process = None
|
|
||||||
self._server_port = None
|
|
||||||
self._token = secrets.token_hex(16)
|
|
||||||
self._page_registry: dict[str, PageOwnerInfo] = {}
|
|
||||||
|
|
||||||
def register(self, instance: WebConsole):
|
|
||||||
"""
|
|
||||||
Register an instance of WebConsole.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instance (WebConsole): The instance to register.
|
|
||||||
"""
|
|
||||||
self._instances[instance.gui_id] = safe_ref(instance)
|
|
||||||
self.cleanup()
|
|
||||||
|
|
||||||
if instance._unique_id:
|
|
||||||
self._register_page(instance)
|
|
||||||
|
|
||||||
if self._server_process is None:
|
|
||||||
# Start the ttyd server if not already running
|
|
||||||
self.start_ttyd()
|
|
||||||
|
|
||||||
def start_ttyd(self, use_zsh: bool | None = None):
|
|
||||||
"""
|
|
||||||
Start the ttyd server
|
|
||||||
ttyd -q -W -t 'theme={"background": "black"}' zsh
|
|
||||||
|
|
||||||
Args:
|
|
||||||
use_zsh (bool): Whether to use zsh or bash. If None, it will try to detect if zsh is available.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# First, check if ttyd is installed
|
|
||||||
try:
|
|
||||||
subprocess.run(["ttyd", "--version"], check=True, stdout=subprocess.PIPE)
|
|
||||||
except FileNotFoundError:
|
|
||||||
# pylint: disable=raise-missing-from
|
|
||||||
raise RuntimeError("ttyd is not installed. Please install it first.")
|
|
||||||
|
|
||||||
if use_zsh is None:
|
|
||||||
# Check if we can use zsh
|
|
||||||
try:
|
|
||||||
subprocess.run(["zsh", "--version"], check=True, stdout=subprocess.PIPE)
|
|
||||||
use_zsh = True
|
|
||||||
except FileNotFoundError:
|
|
||||||
use_zsh = False
|
|
||||||
|
|
||||||
command = [
|
|
||||||
"ttyd",
|
|
||||||
"-p",
|
|
||||||
"0",
|
|
||||||
"-W",
|
|
||||||
"-t",
|
|
||||||
'theme={"background": "black"}',
|
|
||||||
"-c",
|
|
||||||
f"user:{self._token}",
|
|
||||||
]
|
|
||||||
if use_zsh:
|
|
||||||
command.append("zsh")
|
|
||||||
else:
|
|
||||||
command.append("bash")
|
|
||||||
|
|
||||||
# Start the ttyd server
|
|
||||||
self._server_process = subprocess.Popen(
|
|
||||||
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
|
||||||
)
|
|
||||||
|
|
||||||
self._wait_for_server_port()
|
|
||||||
|
|
||||||
self._server_process.stdout.close()
|
|
||||||
self._server_process.stderr.close()
|
|
||||||
|
|
||||||
def _wait_for_server_port(self, timeout: float = 10):
|
|
||||||
"""
|
|
||||||
Wait for the ttyd server to start and get the port number.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
timeout (float): The timeout in seconds to wait for the server to start.
|
|
||||||
"""
|
|
||||||
start_time = time.time()
|
|
||||||
while True:
|
|
||||||
output = self._server_process.stderr.readline()
|
|
||||||
if output == b"" and self._server_process.poll() is not None:
|
|
||||||
break
|
|
||||||
if not output:
|
|
||||||
continue
|
|
||||||
|
|
||||||
output = output.decode("utf-8").strip()
|
|
||||||
if "Listening on" in output:
|
|
||||||
# Extract the port number from the output
|
|
||||||
self._server_port = int(output.split(":")[-1])
|
|
||||||
logger.info(f"ttyd server started on port {self._server_port}")
|
|
||||||
break
|
|
||||||
if time.time() - start_time > timeout:
|
|
||||||
raise TimeoutError(
|
|
||||||
"Timeout waiting for ttyd server to start. Please check if ttyd is installed and available in your PATH."
|
|
||||||
)
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
"""
|
|
||||||
Clean up the registry by removing any instances that are no longer valid.
|
|
||||||
"""
|
|
||||||
for gui_id, weak_ref in list(self._instances.items()):
|
|
||||||
if weak_ref() is None:
|
|
||||||
del self._instances[gui_id]
|
|
||||||
|
|
||||||
if not self._instances and self._server_process:
|
|
||||||
# If no instances are left, terminate the server process
|
|
||||||
self._server_process.terminate()
|
|
||||||
self._server_process = None
|
|
||||||
self._server_port = None
|
|
||||||
logger.info("ttyd server terminated")
|
|
||||||
|
|
||||||
def unregister(self, instance: WebConsole):
|
|
||||||
"""
|
|
||||||
Unregister an instance of WebConsole.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instance (WebConsole): The instance to unregister.
|
|
||||||
"""
|
|
||||||
if instance.gui_id in self._instances:
|
|
||||||
del self._instances[instance.gui_id]
|
|
||||||
|
|
||||||
if instance._unique_id:
|
|
||||||
self._unregister_page(instance._unique_id, instance.gui_id)
|
|
||||||
|
|
||||||
self.cleanup()
|
|
||||||
|
|
||||||
def _register_page(self, instance: WebConsole):
|
|
||||||
"""
|
|
||||||
Register a page in the registry. Please note that this does not transfer ownership
|
|
||||||
for already existing pages; it simply records which widget currently owns the page.
|
|
||||||
Use transfer_page_ownership to change ownership.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instance (WebConsole): The instance to register.
|
|
||||||
"""
|
|
||||||
|
|
||||||
unique_id = instance._unique_id
|
|
||||||
gui_id = instance.gui_id
|
|
||||||
|
|
||||||
if unique_id is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
if unique_id not in self._page_registry:
|
|
||||||
page = BECWebEnginePage()
|
|
||||||
page.authenticationRequired.connect(instance._authenticate)
|
|
||||||
self._page_registry[unique_id] = PageOwnerInfo(
|
|
||||||
owner_gui_id=gui_id, widget_ids=[gui_id], page=page
|
|
||||||
)
|
|
||||||
logger.info(f"Registered new page {unique_id} for {gui_id}")
|
|
||||||
return
|
|
||||||
|
|
||||||
if gui_id not in self._page_registry[unique_id].widget_ids:
|
|
||||||
self._page_registry[unique_id].widget_ids.append(gui_id)
|
|
||||||
|
|
||||||
def _unregister_page(self, unique_id: str, gui_id: str):
|
|
||||||
"""
|
|
||||||
Unregister a page from the registry.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
unique_id (str): The unique identifier for the page.
|
|
||||||
gui_id (str): The GUI ID of the widget.
|
|
||||||
"""
|
|
||||||
if unique_id not in self._page_registry:
|
|
||||||
return
|
|
||||||
page_info = self._page_registry[unique_id]
|
|
||||||
if gui_id in page_info.widget_ids:
|
|
||||||
page_info.widget_ids.remove(gui_id)
|
|
||||||
if page_info.owner_gui_id == gui_id:
|
|
||||||
page_info.owner_gui_id = None
|
|
||||||
if not page_info.widget_ids:
|
|
||||||
if page_info.page:
|
|
||||||
page_info.page.deleteLater()
|
|
||||||
del self._page_registry[unique_id]
|
|
||||||
|
|
||||||
logger.info(f"Unregistered page {unique_id} for {gui_id}")
|
|
||||||
|
|
||||||
def get_page_info(self, unique_id: str) -> PageOwnerInfo | None:
|
|
||||||
"""
|
|
||||||
Get a page from the registry.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
unique_id (str): The unique identifier for the page.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
PageOwnerInfo | None: The page info if found, None otherwise.
|
|
||||||
"""
|
|
||||||
if unique_id not in self._page_registry:
|
|
||||||
return None
|
|
||||||
return self._page_registry[unique_id]
|
|
||||||
|
|
||||||
def take_page_ownership(self, unique_id: str, new_owner_gui_id: str) -> QWebEnginePage | None:
|
|
||||||
"""
|
|
||||||
Transfer ownership of a page to a new owner.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
unique_id (str): The unique identifier for the page.
|
|
||||||
new_owner_gui_id (str): The GUI ID of the new owner.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
QWebEnginePage | None: The page if ownership transfer was successful, None otherwise.
|
|
||||||
"""
|
|
||||||
if unique_id not in self._page_registry:
|
|
||||||
logger.warning(f"Page {unique_id} not found in registry")
|
|
||||||
return None
|
|
||||||
|
|
||||||
page_info = self._page_registry[unique_id]
|
|
||||||
old_owner_gui_id = page_info.owner_gui_id
|
|
||||||
if old_owner_gui_id:
|
|
||||||
old_owner_ref = self._instances.get(old_owner_gui_id)
|
|
||||||
if old_owner_ref:
|
|
||||||
old_owner_instance = old_owner_ref()
|
|
||||||
if old_owner_instance:
|
|
||||||
old_owner_instance.yield_ownership()
|
|
||||||
page_info.owner_gui_id = new_owner_gui_id
|
|
||||||
|
|
||||||
logger.info(f"Transferred ownership of page {unique_id} to {new_owner_gui_id}")
|
|
||||||
return page_info.page
|
|
||||||
|
|
||||||
def yield_ownership(self, gui_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Yield ownership of a page without destroying it. The page remains in the
|
|
||||||
registry with no owner, available for another widget to claim.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
gui_id (str): The GUI ID of the widget yielding ownership.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if ownership was yielded, False otherwise.
|
|
||||||
"""
|
|
||||||
if gui_id not in self._instances:
|
|
||||||
return False
|
|
||||||
|
|
||||||
instance = self._instances[gui_id]()
|
|
||||||
if instance is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
unique_id = instance._unique_id
|
|
||||||
if unique_id is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if unique_id not in self._page_registry:
|
|
||||||
return False
|
|
||||||
|
|
||||||
page_owner_info = self._page_registry[unique_id]
|
|
||||||
if page_owner_info.owner_gui_id != gui_id:
|
|
||||||
return False
|
|
||||||
|
|
||||||
page_owner_info.owner_gui_id = None
|
|
||||||
return True
|
|
||||||
|
|
||||||
def owner_is_visible(self, unique_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if the owner of a page is currently visible.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
unique_id (str): The unique identifier for the page.
|
|
||||||
Returns:
|
|
||||||
bool: True if the owner is visible, False otherwise.
|
|
||||||
"""
|
|
||||||
page_info = self.get_page_info(unique_id)
|
|
||||||
if page_info is None or page_info.owner_gui_id is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
owner_ref = self._instances.get(page_info.owner_gui_id)
|
|
||||||
if owner_ref is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
owner_instance = owner_ref()
|
|
||||||
if owner_instance is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return owner_instance.isVisible()
|
|
||||||
|
|
||||||
|
|
||||||
_web_console_registry = WebConsoleRegistry()
|
|
||||||
|
|
||||||
|
|
||||||
def suppress_qt_messages(type_, context, msg):
|
|
||||||
if context.category in ["js", "default"]:
|
|
||||||
return
|
|
||||||
print(msg)
|
|
||||||
|
|
||||||
|
|
||||||
qInstallMessageHandler(suppress_qt_messages)
|
|
||||||
|
|
||||||
|
|
||||||
class BECWebEnginePage(QWebEnginePage):
|
|
||||||
def javaScriptConsoleMessage(self, level, message, lineNumber, sourceID):
|
|
||||||
logger.info(f"[JS Console] {level.name} at line {lineNumber} in {sourceID}: {message}")
|
|
||||||
|
|
||||||
|
|
||||||
class WebConsole(BECWidget, QWidget):
|
|
||||||
"""
|
|
||||||
A simple widget to display a website
|
|
||||||
"""
|
|
||||||
|
|
||||||
_js_callback = Signal(bool)
|
|
||||||
initialized = Signal()
|
|
||||||
|
|
||||||
PLUGIN = True
|
|
||||||
ICON_NAME = "terminal"
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
parent=None,
|
|
||||||
config=None,
|
|
||||||
client=None,
|
|
||||||
gui_id=None,
|
|
||||||
startup_cmd: str | None = None,
|
|
||||||
is_bec_shell: bool = False,
|
|
||||||
unique_id: str | None = None,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
|
|
||||||
self._mode = ConsoleMode.INACTIVE
|
|
||||||
self._is_bec_shell = is_bec_shell
|
|
||||||
self._startup_cmd = startup_cmd
|
|
||||||
self._is_initialized = False
|
|
||||||
self._unique_id = unique_id
|
|
||||||
self.page = None # Will be set in _set_up_page
|
|
||||||
|
|
||||||
self._startup_timer = QTimer()
|
|
||||||
self._startup_timer.setInterval(500)
|
|
||||||
self._startup_timer.timeout.connect(self._check_page_ready)
|
|
||||||
self._startup_timer.start()
|
|
||||||
self._js_callback.connect(self._on_js_callback)
|
|
||||||
|
|
||||||
self._set_up_page()
|
|
||||||
|
|
||||||
def _set_up_page(self):
|
|
||||||
"""
|
|
||||||
Set up the web page and UI elements.
|
|
||||||
"""
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
layout.setContentsMargins(0, 0, 0, 0)
|
|
||||||
self.browser = QWebEngineView(self)
|
|
||||||
|
|
||||||
layout.addWidget(self.browser)
|
|
||||||
self.setLayout(layout)
|
|
||||||
|
|
||||||
# prepare overlay
|
|
||||||
self.overlay = QWidget(self)
|
|
||||||
layout = QVBoxLayout(self.overlay)
|
|
||||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
||||||
label = QLabel("Click to activate terminal", self.overlay)
|
|
||||||
layout.addWidget(label)
|
|
||||||
self.overlay.hide()
|
|
||||||
|
|
||||||
_web_console_registry.register(self)
|
|
||||||
self._token = _web_console_registry._token
|
|
||||||
|
|
||||||
# If no unique_id is provided, create a new page
|
|
||||||
if not self._unique_id:
|
|
||||||
self.page = BECWebEnginePage(self)
|
|
||||||
self.page.authenticationRequired.connect(self._authenticate)
|
|
||||||
self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}"))
|
|
||||||
self.browser.setPage(self.page)
|
|
||||||
self._set_mode(ConsoleMode.ACTIVE)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Try to get the page from the registry
|
|
||||||
page_info = _web_console_registry.get_page_info(self._unique_id)
|
|
||||||
if page_info and page_info.page:
|
|
||||||
self.page = page_info.page
|
|
||||||
if not page_info.owner_gui_id or page_info.owner_gui_id == self.gui_id:
|
|
||||||
self.browser.setPage(self.page)
|
|
||||||
# Only set URL if this is a newly created page (no URL set yet)
|
|
||||||
if self.page.url().isEmpty():
|
|
||||||
self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}"))
|
|
||||||
else:
|
|
||||||
# We have an existing page, so we don't need the startup timer
|
|
||||||
self._startup_timer.stop()
|
|
||||||
if page_info.owner_gui_id != self.gui_id:
|
|
||||||
self._set_mode(ConsoleMode.INACTIVE)
|
|
||||||
else:
|
|
||||||
self._set_mode(ConsoleMode.ACTIVE)
|
|
||||||
|
|
||||||
def _set_mode(self, mode: ConsoleMode):
|
|
||||||
"""
|
|
||||||
Set the mode of the web console.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
mode (ConsoleMode): The mode to set.
|
|
||||||
"""
|
|
||||||
if not self._unique_id:
|
|
||||||
# For non-unique_id consoles, always active
|
|
||||||
mode = ConsoleMode.ACTIVE
|
|
||||||
|
|
||||||
self._mode = mode
|
|
||||||
match mode:
|
|
||||||
case ConsoleMode.ACTIVE:
|
|
||||||
self.browser.setVisible(True)
|
|
||||||
self.overlay.hide()
|
|
||||||
case ConsoleMode.INACTIVE:
|
|
||||||
self.browser.setVisible(False)
|
|
||||||
self.overlay.show()
|
|
||||||
case ConsoleMode.HIDDEN:
|
|
||||||
self.browser.setVisible(False)
|
|
||||||
self.overlay.hide()
|
|
||||||
|
|
||||||
def _check_page_ready(self):
|
|
||||||
"""
|
|
||||||
Check if the page is ready and stop the timer if it is.
|
|
||||||
"""
|
|
||||||
if not self.page or self.page.isLoading():
|
|
||||||
return
|
|
||||||
|
|
||||||
self.page.runJavaScript("window.term !== undefined", self._js_callback.emit)
|
|
||||||
|
|
||||||
def _on_js_callback(self, ready: bool):
|
|
||||||
"""
|
|
||||||
Callback for when the JavaScript is ready.
|
|
||||||
"""
|
|
||||||
if not ready:
|
|
||||||
return
|
|
||||||
self._is_initialized = True
|
|
||||||
self._startup_timer.stop()
|
|
||||||
if self.startup_cmd:
|
|
||||||
if self._unique_id:
|
|
||||||
page_info = _web_console_registry.get_page_info(self._unique_id)
|
|
||||||
if page_info is None:
|
|
||||||
return
|
|
||||||
if not page_info.initialized:
|
|
||||||
page_info.initialized = True
|
|
||||||
self.write(self.startup_cmd)
|
|
||||||
else:
|
|
||||||
self.write(self.startup_cmd)
|
|
||||||
self.initialized.emit()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def startup_cmd(self):
|
|
||||||
"""
|
|
||||||
Get the startup command for the web console.
|
|
||||||
"""
|
|
||||||
if self._is_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}"
|
|
||||||
return self._startup_cmd
|
|
||||||
|
|
||||||
@startup_cmd.setter
|
|
||||||
def startup_cmd(self, cmd: str):
|
|
||||||
"""
|
|
||||||
Set the startup command for the web console.
|
|
||||||
"""
|
|
||||||
if not isinstance(cmd, str):
|
|
||||||
raise ValueError("Startup command must be a string.")
|
|
||||||
self._startup_cmd = cmd
|
|
||||||
|
|
||||||
def write(self, data: str, send_return: bool = True):
|
|
||||||
"""
|
|
||||||
Send data to the web page
|
|
||||||
|
|
||||||
Args:
|
|
||||||
data (str): The data to send.
|
|
||||||
send_return (bool): Whether to send a return after the data.
|
|
||||||
"""
|
|
||||||
cmd = f"window.term.paste({json.dumps(data)});"
|
|
||||||
if self.page is None:
|
|
||||||
logger.warning("Cannot write to web console: page is not initialized.")
|
|
||||||
return
|
|
||||||
self.page.runJavaScript(cmd)
|
|
||||||
if send_return:
|
|
||||||
self.send_return()
|
|
||||||
|
|
||||||
def take_page_ownership(self, unique_id: str | None = None):
|
|
||||||
"""
|
|
||||||
Take ownership of a web page from the registry. This will transfer the page
|
|
||||||
from its current owner (if any) to this widget.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
unique_id (str): The unique identifier of the page to take ownership of.
|
|
||||||
If None, uses this widget's unique_id.
|
|
||||||
"""
|
|
||||||
if unique_id is None:
|
|
||||||
unique_id = self._unique_id
|
|
||||||
|
|
||||||
if not unique_id:
|
|
||||||
logger.warning("Cannot take page ownership without a unique_id")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get the page from registry
|
|
||||||
page = _web_console_registry.take_page_ownership(unique_id, self.gui_id)
|
|
||||||
|
|
||||||
if not page:
|
|
||||||
logger.warning(f"Page {unique_id} not found in registry")
|
|
||||||
return
|
|
||||||
|
|
||||||
self.page = page
|
|
||||||
self.browser.setPage(page)
|
|
||||||
self._set_mode(ConsoleMode.ACTIVE)
|
|
||||||
logger.info(f"Widget {self.gui_id} took ownership of page {unique_id}")
|
|
||||||
|
|
||||||
def _on_ownership_lost(self):
|
|
||||||
"""
|
|
||||||
Called when this widget loses ownership of its page.
|
|
||||||
Displays the overlay and hides the browser.
|
|
||||||
"""
|
|
||||||
self._set_mode(ConsoleMode.INACTIVE)
|
|
||||||
logger.info(f"Widget {self.gui_id} lost ownership of page {self._unique_id}")
|
|
||||||
|
|
||||||
def yield_ownership(self):
|
|
||||||
"""
|
|
||||||
Yield ownership of the page. The page remains in the registry with no owner,
|
|
||||||
available for another widget to claim. This is automatically called when the
|
|
||||||
widget becomes hidden.
|
|
||||||
"""
|
|
||||||
if not self._unique_id:
|
|
||||||
return
|
|
||||||
success = _web_console_registry.yield_ownership(self.gui_id)
|
|
||||||
if success:
|
|
||||||
self._on_ownership_lost()
|
|
||||||
logger.info(f"Widget {self.gui_id} yielded ownership of page {self._unique_id}")
|
|
||||||
|
|
||||||
def has_ownership(self) -> bool:
|
|
||||||
"""
|
|
||||||
Check if this widget currently has ownership of a page.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if this widget owns a page, False otherwise.
|
|
||||||
"""
|
|
||||||
if not self._unique_id:
|
|
||||||
return False
|
|
||||||
page_info = _web_console_registry.get_page_info(self._unique_id)
|
|
||||||
if page_info is None:
|
|
||||||
return False
|
|
||||||
return page_info.owner_gui_id == self.gui_id
|
|
||||||
|
|
||||||
def hideEvent(self, event):
|
|
||||||
"""
|
|
||||||
Called when the widget is hidden. Automatically yields ownership.
|
|
||||||
"""
|
|
||||||
if self.has_ownership():
|
|
||||||
self.yield_ownership()
|
|
||||||
self._set_mode(ConsoleMode.HIDDEN)
|
|
||||||
super().hideEvent(event)
|
|
||||||
|
|
||||||
def showEvent(self, event):
|
|
||||||
"""
|
|
||||||
Called when the widget is shown. Updates UI state based on ownership.
|
|
||||||
"""
|
|
||||||
super().showEvent(event)
|
|
||||||
if self._unique_id and not self.has_ownership():
|
|
||||||
# Take ownership if the page does not have an owner or
|
|
||||||
# the owner is not visible
|
|
||||||
page_info = _web_console_registry.get_page_info(self._unique_id)
|
|
||||||
if page_info is None:
|
|
||||||
self._set_mode(ConsoleMode.INACTIVE)
|
|
||||||
return
|
|
||||||
if page_info.owner_gui_id is None or not _web_console_registry.owner_is_visible(
|
|
||||||
self._unique_id
|
|
||||||
):
|
|
||||||
self.take_page_ownership(self._unique_id)
|
|
||||||
return
|
|
||||||
if page_info.owner_gui_id != self.gui_id:
|
|
||||||
self._set_mode(ConsoleMode.INACTIVE)
|
|
||||||
return
|
|
||||||
|
|
||||||
def resizeEvent(self, event: QResizeEvent) -> None:
|
|
||||||
super().resizeEvent(event)
|
|
||||||
self.overlay.resize(event.size())
|
|
||||||
|
|
||||||
def mousePressEvent(self, event: QMouseEvent) -> None:
|
|
||||||
if event.button() == Qt.MouseButton.LeftButton and not self.has_ownership():
|
|
||||||
self.take_page_ownership(self._unique_id)
|
|
||||||
event.accept()
|
|
||||||
return
|
|
||||||
return super().mousePressEvent(event)
|
|
||||||
|
|
||||||
def _authenticate(self, _, auth):
|
|
||||||
"""
|
|
||||||
Authenticate the request with the provided username and password.
|
|
||||||
"""
|
|
||||||
auth.setUser("user")
|
|
||||||
auth.setPassword(self._token)
|
|
||||||
|
|
||||||
def send_return(self):
|
|
||||||
"""
|
|
||||||
Send return to the web page
|
|
||||||
"""
|
|
||||||
self.page.runJavaScript(
|
|
||||||
"document.querySelector('textarea.xterm-helper-textarea').dispatchEvent(new KeyboardEvent('keypress', {charCode: 13}))"
|
|
||||||
)
|
|
||||||
|
|
||||||
def send_ctrl_c(self):
|
|
||||||
"""
|
|
||||||
Send Ctrl+C to the web page
|
|
||||||
"""
|
|
||||||
self.page.runJavaScript(
|
|
||||||
"document.querySelector('textarea.xterm-helper-textarea').dispatchEvent(new KeyboardEvent('keypress', {charCode: 3}))"
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_readonly(self, readonly: bool):
|
|
||||||
"""
|
|
||||||
Set the web console to read-only mode.
|
|
||||||
"""
|
|
||||||
if not isinstance(readonly, bool):
|
|
||||||
raise ValueError("Readonly must be a boolean.")
|
|
||||||
self.setEnabled(not readonly)
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
"""
|
|
||||||
Clean up the registry by removing any instances that are no longer valid.
|
|
||||||
"""
|
|
||||||
self._startup_timer.stop()
|
|
||||||
_web_console_registry.unregister(self)
|
|
||||||
super().cleanup()
|
|
||||||
|
|
||||||
|
|
||||||
class BECShell(WebConsole):
|
|
||||||
"""
|
|
||||||
A WebConsole 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"
|
|
||||||
|
|
||||||
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,
|
|
||||||
is_bec_shell=True,
|
|
||||||
unique_id="bec_shell",
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
import sys
|
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
|
||||||
widget = QTabWidget()
|
|
||||||
|
|
||||||
# Create two consoles with different unique_ids
|
|
||||||
web_console1 = WebConsole(startup_cmd="bec --nogui", unique_id="console1")
|
|
||||||
web_console2 = WebConsole(startup_cmd="htop")
|
|
||||||
web_console3 = WebConsole(startup_cmd="bec --nogui", unique_id="console1")
|
|
||||||
widget.addTab(web_console1, "Console 1")
|
|
||||||
widget.addTab(web_console2, "Console 2")
|
|
||||||
widget.addTab(web_console3, "Console 3 -- mirror of Console 1")
|
|
||||||
widget.show()
|
|
||||||
|
|
||||||
# Demonstrate page sharing:
|
|
||||||
# After initialization, web_console2 can take ownership of console1's page:
|
|
||||||
# web_console2.take_page_ownership("console1")
|
|
||||||
|
|
||||||
widget.resize(800, 600)
|
|
||||||
|
|
||||||
def _close_cons1():
|
|
||||||
web_console2.close()
|
|
||||||
web_console2.deleteLater()
|
|
||||||
|
|
||||||
# QTimer.singleShot(3000, _close_cons1)
|
|
||||||
|
|
||||||
sys.exit(app.exec_())
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{'files': ['web_console.py']}
|
|
||||||
@@ -19,8 +19,8 @@ from scipy.interpolate import (
|
|||||||
from scipy.spatial import cKDTree
|
from scipy.spatial import cKDTree
|
||||||
from toolz import partition
|
from toolz import partition
|
||||||
|
|
||||||
from bec_widgets.utils import Colors
|
|
||||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||||
|
from bec_widgets.utils.colors import Colors
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||||
@@ -611,7 +611,7 @@ class Heatmap(ImageBase):
|
|||||||
scan_msg = self.scan_item.status_message
|
scan_msg = self.scan_item.status_message
|
||||||
elif hasattr(self.scan_item, "metadata"):
|
elif hasattr(self.scan_item, "metadata"):
|
||||||
metadata = self.scan_item.metadata["bec"]
|
metadata = self.scan_item.metadata["bec"]
|
||||||
status = metadata["exit_status"]
|
status = metadata["status"]
|
||||||
scan_id = metadata["scan_id"]
|
scan_id = metadata["scan_id"]
|
||||||
scan_name = metadata["scan_name"]
|
scan_name = metadata["scan_name"]
|
||||||
scan_type = metadata["scan_type"]
|
scan_type = metadata["scan_type"]
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import os
|
|||||||
|
|
||||||
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout
|
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout
|
||||||
|
|
||||||
from bec_widgets.utils import UILoader
|
|
||||||
from bec_widgets.utils.error_popups import SafeSlot
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
from bec_widgets.utils.settings_dialog import SettingWidget
|
from bec_widgets.utils.settings_dialog import SettingWidget
|
||||||
|
from bec_widgets.utils.ui_loader import UILoader
|
||||||
|
|
||||||
|
|
||||||
class HeatmapSettings(SettingWidget):
|
class HeatmapSettings(SettingWidget):
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from pydantic import BaseModel, Field, field_validator
|
|||||||
from qtpy.QtCore import QTimer
|
from qtpy.QtCore import QTimer
|
||||||
from qtpy.QtWidgets import QWidget
|
from qtpy.QtWidgets import QWidget
|
||||||
|
|
||||||
from bec_widgets.utils import ConnectionConfig
|
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||||
from bec_widgets.utils.colors import Colors, apply_theme
|
from bec_widgets.utils.colors import Colors, apply_theme
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
from bec_widgets.widgets.plots.image.image_base import ImageBase
|
from bec_widgets.widgets.plots.image.image_base import ImageBase
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|||||||
from qtpy.QtCore import QPointF, Signal, SignalInstance
|
from qtpy.QtCore import QPointF, Signal, SignalInstance
|
||||||
from qtpy.QtWidgets import QDialog, QVBoxLayout
|
from qtpy.QtWidgets import QDialog, QVBoxLayout
|
||||||
|
|
||||||
from bec_widgets.utils import Colors
|
from bec_widgets.utils.colors import Colors
|
||||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
from bec_widgets.utils.side_panel import SidePanel
|
from bec_widgets.utils.side_panel import SidePanel
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ from pydantic import Field, ValidationError, field_validator
|
|||||||
from qtpy.QtCore import Signal
|
from qtpy.QtCore import Signal
|
||||||
from qtpy.QtGui import QTransform
|
from qtpy.QtGui import QTransform
|
||||||
|
|
||||||
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
|
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||||
|
from bec_widgets.utils.colors import Colors
|
||||||
from bec_widgets.widgets.plots.image.image_processor import (
|
from bec_widgets.widgets.plots.image.image_processor import (
|
||||||
ImageProcessor,
|
ImageProcessor,
|
||||||
ImageStats,
|
ImageStats,
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ from qtpy.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from bec_widgets import BECWidget
|
from bec_widgets import BECWidget
|
||||||
from bec_widgets.utils import BECDispatcher, ConnectionConfig
|
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||||
|
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||||
from bec_widgets.utils.toolbars.actions import WidgetAction
|
from bec_widgets.utils.toolbars.actions import WidgetAction
|
||||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
|
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ from qtpy.QtCore import Signal
|
|||||||
from qtpy.QtGui import QColor
|
from qtpy.QtGui import QColor
|
||||||
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
|
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils import Colors, ConnectionConfig
|
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||||
from bec_widgets.utils.colors import apply_theme
|
from bec_widgets.utils.colors import Colors, apply_theme
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import os
|
|||||||
|
|
||||||
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout, QWidget
|
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils import UILoader
|
|
||||||
from bec_widgets.utils.error_popups import SafeSlot
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
from bec_widgets.utils.settings_dialog import SettingWidget
|
from bec_widgets.utils.settings_dialog import SettingWidget
|
||||||
|
from bec_widgets.utils.ui_loader import UILoader
|
||||||
from bec_widgets.utils.widget_io import WidgetIO
|
from bec_widgets.utils.widget_io import WidgetIO
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ from pydantic import Field, ValidationError, field_validator
|
|||||||
from qtpy.QtCore import Signal
|
from qtpy.QtCore import Signal
|
||||||
from qtpy.QtWidgets import QWidget
|
from qtpy.QtWidgets import QWidget
|
||||||
|
|
||||||
from bec_widgets.utils import Colors, ConnectionConfig
|
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||||
|
from bec_widgets.utils.colors import Colors
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
from bec_widgets.utils.side_panel import SidePanel
|
from bec_widgets.utils.side_panel import SidePanel
|
||||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import os
|
|||||||
|
|
||||||
from qtpy.QtWidgets import QVBoxLayout, QWidget
|
from qtpy.QtWidgets import QVBoxLayout, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils import UILoader
|
|
||||||
from bec_widgets.utils.error_popups import SafeSlot
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
from bec_widgets.utils.settings_dialog import SettingWidget
|
from bec_widgets.utils.settings_dialog import SettingWidget
|
||||||
|
from bec_widgets.utils.ui_loader import UILoader
|
||||||
from bec_widgets.utils.widget_io import WidgetIO
|
from bec_widgets.utils.widget_io import WidgetIO
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ from bec_lib import bec_logger
|
|||||||
from qtpy.QtCore import QPoint, QPointF, Qt, Signal
|
from qtpy.QtCore import QPoint, QPointF, Qt, Signal
|
||||||
from qtpy.QtWidgets import QHBoxLayout, QLabel, QMainWindow, QVBoxLayout, QWidget
|
from qtpy.QtWidgets import QHBoxLayout, QLabel, QMainWindow, QVBoxLayout, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils import ConnectionConfig, Crosshair, EntryValidator
|
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
|
from bec_widgets.utils.crosshair import Crosshair
|
||||||
|
from bec_widgets.utils.entry_validator import EntryValidator
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
from bec_widgets.utils.fps_counter import FPSCounter
|
from bec_widgets.utils.fps_counter import FPSCounter
|
||||||
from bec_widgets.utils.plot_indicator_items import BECArrowItem, BECTickItem
|
from bec_widgets.utils.plot_indicator_items import BECArrowItem, BECTickItem
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from qtpy import QtCore
|
|||||||
from qtpy.QtCore import QObject, Signal
|
from qtpy.QtCore import QObject, Signal
|
||||||
|
|
||||||
from bec_widgets import SafeProperty
|
from bec_widgets import SafeProperty
|
||||||
from bec_widgets.utils import BECConnector, ConnectionConfig
|
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||||
from bec_widgets.utils.colors import Colors
|
from bec_widgets.utils.colors import Colors
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ from bec_lib import bec_logger
|
|||||||
from pydantic import BaseModel, Field, ValidationError, field_validator
|
from pydantic import BaseModel, Field, ValidationError, field_validator
|
||||||
from qtpy import QtCore
|
from qtpy import QtCore
|
||||||
|
|
||||||
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
|
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||||
|
from bec_widgets.utils.colors import Colors
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
|
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ from pydantic import Field, ValidationError, field_validator
|
|||||||
from qtpy.QtCore import QTimer, Signal
|
from qtpy.QtCore import QTimer, Signal
|
||||||
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
|
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils import Colors, ConnectionConfig
|
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||||
|
from bec_widgets.utils.colors import Colors
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import os
|
|||||||
|
|
||||||
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout
|
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout
|
||||||
|
|
||||||
from bec_widgets.utils import UILoader
|
|
||||||
from bec_widgets.utils.error_popups import SafeSlot
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
from bec_widgets.utils.settings_dialog import SettingWidget
|
from bec_widgets.utils.settings_dialog import SettingWidget
|
||||||
|
from bec_widgets.utils.ui_loader import UILoader
|
||||||
|
|
||||||
|
|
||||||
class ScatterCurveSettings(SettingWidget):
|
class ScatterCurveSettings(SettingWidget):
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import os
|
|||||||
|
|
||||||
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout, QWidget
|
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils import UILoader
|
|
||||||
from bec_widgets.utils.error_popups import SafeSlot
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
from bec_widgets.utils.settings_dialog import SettingWidget
|
from bec_widgets.utils.settings_dialog import SettingWidget
|
||||||
|
from bec_widgets.utils.ui_loader import UILoader
|
||||||
from bec_widgets.utils.widget_io import WidgetIO
|
from bec_widgets.utils.widget_io import WidgetIO
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ from bec_lib import bec_logger
|
|||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from qtpy import QtCore
|
from qtpy import QtCore
|
||||||
|
|
||||||
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
|
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||||
|
from bec_widgets.utils.colors import Colors
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||||
|
|||||||
@@ -50,9 +50,10 @@ from qtpy.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from bec_widgets import SafeSlot
|
from bec_widgets import SafeSlot
|
||||||
from bec_widgets.utils import ConnectionConfig, EntryValidator
|
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
from bec_widgets.utils.colors import Colors
|
from bec_widgets.utils.colors import Colors
|
||||||
|
from bec_widgets.utils.entry_validator import EntryValidator
|
||||||
from bec_widgets.utils.toolbars.actions import WidgetAction
|
from bec_widgets.utils.toolbars.actions import WidgetAction
|
||||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
|
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import pyqtgraph as pg
|
||||||
|
from qtpy.QtCore import QObject, Qt, Signal
|
||||||
|
from qtpy.QtGui import QColor
|
||||||
|
|
||||||
|
from bec_widgets.utils.colors import get_accent_colors, get_theme_name
|
||||||
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
|
from bec_widgets.widgets.plots.waveform.utils.alignment_panel import WaveformAlignmentPanel
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AlignmentContext:
|
||||||
|
"""Alignment state produced by `Waveform` and consumed by the controller.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
visible: Whether alignment mode is currently visible.
|
||||||
|
positioner_name: Name of the resolved x-axis positioner, if available.
|
||||||
|
precision: Decimal precision to use for readback and target labels.
|
||||||
|
limits: Optional positioner limits for the draggable target line.
|
||||||
|
readback: Current cached positioner readback value.
|
||||||
|
has_dap_curves: Whether the waveform currently contains any DAP curves.
|
||||||
|
force_readback: Whether the embedded positioner should refresh its readback immediately.
|
||||||
|
"""
|
||||||
|
|
||||||
|
visible: bool
|
||||||
|
positioner_name: str | None
|
||||||
|
precision: int = 3
|
||||||
|
limits: tuple[float, float] | None = None
|
||||||
|
readback: float | None = None
|
||||||
|
has_dap_curves: bool = False
|
||||||
|
force_readback: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class WaveformAlignmentController(QObject):
|
||||||
|
"""Own the alignment plot overlays and synchronize them with the alignment panel."""
|
||||||
|
|
||||||
|
move_absolute_requested = Signal(float)
|
||||||
|
autoscale_requested = Signal()
|
||||||
|
|
||||||
|
def __init__(self, plot_item: pg.PlotItem, panel: WaveformAlignmentPanel, parent=None):
|
||||||
|
super().__init__(parent=parent)
|
||||||
|
self._plot_item = plot_item
|
||||||
|
self._panel = panel
|
||||||
|
|
||||||
|
self._visible = False
|
||||||
|
self._positioner_name: str | None = None
|
||||||
|
self._precision = 3
|
||||||
|
self._limits: tuple[float, float] | None = None
|
||||||
|
self._readback: float | None = None
|
||||||
|
self._marker_line: pg.InfiniteLine | None = None
|
||||||
|
self._target_line: pg.InfiniteLine | None = None
|
||||||
|
|
||||||
|
self._panel.position_readback_changed.connect(self.update_position)
|
||||||
|
self._panel.target_toggled.connect(self._on_target_toggled)
|
||||||
|
self._panel.target_move_requested.connect(self._on_target_move_requested)
|
||||||
|
self._panel.fit_selection_changed.connect(self._on_fit_selection_changed)
|
||||||
|
self._panel.fit_center_requested.connect(self._on_fit_center_requested)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def marker_line(self) -> pg.InfiniteLine | None:
|
||||||
|
"""Return the current-position indicator line, if it exists."""
|
||||||
|
return self._marker_line
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_line(self) -> pg.InfiniteLine | None:
|
||||||
|
"""Return the draggable target indicator line, if it exists."""
|
||||||
|
return self._target_line
|
||||||
|
|
||||||
|
def update_context(self, context: AlignmentContext):
|
||||||
|
"""Apply waveform-owned alignment context to the panel and plot overlays.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
context: Snapshot of the current alignment-relevant waveform/device state.
|
||||||
|
"""
|
||||||
|
previous_name = self._positioner_name
|
||||||
|
self._visible = context.visible
|
||||||
|
self._positioner_name = context.positioner_name
|
||||||
|
self._precision = context.precision
|
||||||
|
self._limits = context.limits
|
||||||
|
self._readback = context.readback
|
||||||
|
|
||||||
|
self._panel.set_positioner_device(context.positioner_name)
|
||||||
|
self._panel.set_positioner_enabled(context.visible and context.positioner_name is not None)
|
||||||
|
self._panel.set_status_message(self._status_message_for_context(context))
|
||||||
|
|
||||||
|
if context.positioner_name is None or not context.visible:
|
||||||
|
self.clear()
|
||||||
|
self._refresh_fit_actions()
|
||||||
|
self._refresh_target_controls()
|
||||||
|
return
|
||||||
|
|
||||||
|
if previous_name != context.positioner_name:
|
||||||
|
self._clear_marker()
|
||||||
|
if self._panel.target_active:
|
||||||
|
self._clear_target_line()
|
||||||
|
|
||||||
|
if context.readback is not None:
|
||||||
|
self.update_position(context.readback)
|
||||||
|
|
||||||
|
if self._panel.target_active:
|
||||||
|
if previous_name != context.positioner_name or self._target_line is None:
|
||||||
|
self._show_target_line()
|
||||||
|
else:
|
||||||
|
self._refresh_target_line_metadata()
|
||||||
|
self._on_target_line_changed()
|
||||||
|
|
||||||
|
if context.force_readback or previous_name != context.positioner_name:
|
||||||
|
self._panel.force_positioner_readback()
|
||||||
|
|
||||||
|
self._refresh_fit_actions()
|
||||||
|
self._refresh_target_controls()
|
||||||
|
|
||||||
|
@SafeSlot(float)
|
||||||
|
def update_position(self, position: float):
|
||||||
|
"""Update the live position marker from a positioner readback value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
position: Current absolute position of the active alignment positioner.
|
||||||
|
"""
|
||||||
|
self._readback = float(position)
|
||||||
|
if not self._visible or self._positioner_name is None:
|
||||||
|
self._clear_marker()
|
||||||
|
return
|
||||||
|
|
||||||
|
self._ensure_marker()
|
||||||
|
self._marker_line.setValue(self._readback)
|
||||||
|
self._marker_line.label.setText(
|
||||||
|
f"{self._positioner_name}: {self._readback:.{self._precision}f}"
|
||||||
|
)
|
||||||
|
self.autoscale_requested.emit()
|
||||||
|
|
||||||
|
@SafeSlot(dict, dict)
|
||||||
|
def update_dap_summary(self, data: dict, metadata: dict):
|
||||||
|
"""Forward DAP summary updates into the alignment fit panel.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: DAP fit summary payload.
|
||||||
|
metadata: Metadata describing the emitting DAP curve.
|
||||||
|
"""
|
||||||
|
self._panel.update_dap_summary(data, metadata)
|
||||||
|
self._refresh_fit_actions()
|
||||||
|
|
||||||
|
@SafeSlot(str)
|
||||||
|
def remove_dap_curve(self, curve_id: str):
|
||||||
|
"""Remove a deleted DAP curve from the alignment fit selection state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
curve_id: Label of the DAP curve that was removed from the waveform.
|
||||||
|
"""
|
||||||
|
self._panel.remove_dap_curve(curve_id)
|
||||||
|
self._panel.clear_fit_selection_if_missing()
|
||||||
|
self._refresh_fit_actions()
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
"""Remove alignment overlay items from the plot and reset target state."""
|
||||||
|
self._clear_marker()
|
||||||
|
self._clear_target_line()
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
"""Disconnect panel signals and remove all controller-owned overlay items."""
|
||||||
|
self.clear()
|
||||||
|
self._disconnect_panel_signals()
|
||||||
|
|
||||||
|
def refresh_theme_colors(self):
|
||||||
|
"""Reapply theme-aware styling to any existing alignment overlay items."""
|
||||||
|
self._apply_marker_style()
|
||||||
|
self._apply_target_style()
|
||||||
|
|
||||||
|
def _disconnect_panel_signals(self):
|
||||||
|
signal_pairs = [
|
||||||
|
(self._panel.position_readback_changed, self.update_position),
|
||||||
|
(self._panel.target_toggled, self._on_target_toggled),
|
||||||
|
(self._panel.target_move_requested, self._on_target_move_requested),
|
||||||
|
(self._panel.fit_selection_changed, self._on_fit_selection_changed),
|
||||||
|
(self._panel.fit_center_requested, self._on_fit_center_requested),
|
||||||
|
]
|
||||||
|
for signal, slot in signal_pairs:
|
||||||
|
try:
|
||||||
|
signal.disconnect(slot)
|
||||||
|
except (RuntimeError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
def _selected_fit_has_center(self) -> bool:
|
||||||
|
data = self._panel.selected_fit_summary()
|
||||||
|
params = data.get("params", []) if isinstance(data, dict) else []
|
||||||
|
return any(param[0] == "center" for param in params if param)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _status_message_for_context(context: AlignmentContext) -> str | None:
|
||||||
|
if context.positioner_name is None:
|
||||||
|
return "Alignment mode requires a positioner on the x axis."
|
||||||
|
if not context.has_dap_curves:
|
||||||
|
return "Add a DAP curve in Curve Settings to enable alignment fitting."
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _refresh_fit_actions(self):
|
||||||
|
self._panel.set_fit_actions_enabled(
|
||||||
|
self._visible and self._positioner_name is not None and self._selected_fit_has_center()
|
||||||
|
)
|
||||||
|
|
||||||
|
def _refresh_target_controls(self):
|
||||||
|
has_positioner = self._visible and self._positioner_name is not None
|
||||||
|
self._panel.set_target_enabled(has_positioner)
|
||||||
|
self._panel.set_target_move_enabled(has_positioner and self._target_line is not None)
|
||||||
|
if self._target_line is None:
|
||||||
|
self._panel.set_target_value(None)
|
||||||
|
|
||||||
|
def _ensure_marker(self):
|
||||||
|
if self._marker_line is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
warning = get_accent_colors().warning
|
||||||
|
|
||||||
|
self._marker_line = pg.InfiniteLine(
|
||||||
|
angle=90,
|
||||||
|
movable=False,
|
||||||
|
pen=pg.mkPen(warning, width=4),
|
||||||
|
label="",
|
||||||
|
labelOpts={"position": 0.95, "color": warning},
|
||||||
|
)
|
||||||
|
self._apply_marker_style()
|
||||||
|
self._plot_item.addItem(self._marker_line)
|
||||||
|
|
||||||
|
def _clear_marker(self):
|
||||||
|
if self._marker_line is None:
|
||||||
|
return
|
||||||
|
self._plot_item.removeItem(self._marker_line)
|
||||||
|
self._marker_line = None
|
||||||
|
|
||||||
|
def _show_target_line(self):
|
||||||
|
if not self._visible or self._positioner_name is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._target_line is None:
|
||||||
|
accent_colors = get_accent_colors()
|
||||||
|
label = f"{self._positioner_name} target={{value:0.{self._precision}f}}"
|
||||||
|
self._target_line = pg.InfiniteLine(
|
||||||
|
movable=True,
|
||||||
|
angle=90,
|
||||||
|
pen=pg.mkPen(accent_colors.default, width=2, style=Qt.PenStyle.DashLine),
|
||||||
|
hoverPen=pg.mkPen(accent_colors.success, width=2),
|
||||||
|
label=label,
|
||||||
|
labelOpts={"movable": True, "color": accent_colors.default},
|
||||||
|
)
|
||||||
|
self._target_line.sigPositionChanged.connect(self._on_target_line_changed)
|
||||||
|
self._apply_target_style()
|
||||||
|
self._plot_item.addItem(self._target_line)
|
||||||
|
self._refresh_target_line_metadata()
|
||||||
|
|
||||||
|
value = 0.0 if self._readback is None else self._readback
|
||||||
|
if self._limits is not None:
|
||||||
|
value = min(max(value, self._limits[0]), self._limits[1])
|
||||||
|
self._target_line.setValue(value)
|
||||||
|
self._on_target_line_changed()
|
||||||
|
self.autoscale_requested.emit()
|
||||||
|
|
||||||
|
def _refresh_target_line_metadata(self):
|
||||||
|
if self._target_line is None or self._positioner_name is None:
|
||||||
|
return
|
||||||
|
self._apply_target_style()
|
||||||
|
self._target_line.label.setFormat(
|
||||||
|
f"{self._positioner_name} target={{value:0.{self._precision}f}}"
|
||||||
|
)
|
||||||
|
if self._limits is not None:
|
||||||
|
self._target_line.setBounds(list(self._limits))
|
||||||
|
else:
|
||||||
|
self._target_line.setBounds((None, None))
|
||||||
|
if self._limits is not None:
|
||||||
|
current_value = float(self._target_line.value())
|
||||||
|
clamped_value = min(max(current_value, self._limits[0]), self._limits[1])
|
||||||
|
if clamped_value != current_value:
|
||||||
|
self._target_line.setValue(clamped_value)
|
||||||
|
|
||||||
|
def _clear_target_line(self):
|
||||||
|
if self._target_line is not None:
|
||||||
|
try:
|
||||||
|
self._target_line.sigPositionChanged.disconnect(self._on_target_line_changed)
|
||||||
|
except (RuntimeError, TypeError):
|
||||||
|
pass
|
||||||
|
self._plot_item.removeItem(self._target_line)
|
||||||
|
self._target_line = None
|
||||||
|
self._panel.set_target_value(None)
|
||||||
|
|
||||||
|
def _apply_marker_style(self):
|
||||||
|
if self._marker_line is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
accent_colors = get_accent_colors()
|
||||||
|
warning = accent_colors.warning
|
||||||
|
|
||||||
|
self._marker_line.setPen(pg.mkPen(warning, width=4))
|
||||||
|
self._marker_line.label.setColor(warning)
|
||||||
|
self._marker_line.label.fill = pg.mkBrush(self._label_fill_color())
|
||||||
|
|
||||||
|
def _apply_target_style(self):
|
||||||
|
if self._target_line is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
accent_colors = get_accent_colors()
|
||||||
|
default = accent_colors.default
|
||||||
|
success = accent_colors.success
|
||||||
|
|
||||||
|
self._target_line.setPen(pg.mkPen(default, width=2, style=Qt.PenStyle.DashLine))
|
||||||
|
self._target_line.setHoverPen(pg.mkPen(success, width=2))
|
||||||
|
self._target_line.label.setColor(default)
|
||||||
|
self._target_line.label.fill = pg.mkBrush(self._label_fill_color())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _label_fill_color() -> QColor:
|
||||||
|
if get_theme_name() == "light":
|
||||||
|
return QColor(244, 244, 244, 228)
|
||||||
|
return QColor(48, 48, 48, 210)
|
||||||
|
|
||||||
|
@SafeSlot(bool)
|
||||||
|
def _on_target_toggled(self, checked: bool):
|
||||||
|
if checked:
|
||||||
|
self._show_target_line()
|
||||||
|
else:
|
||||||
|
self._clear_target_line()
|
||||||
|
self._refresh_target_controls()
|
||||||
|
|
||||||
|
@SafeSlot(object)
|
||||||
|
def _on_target_line_changed(self, _line=None):
|
||||||
|
if self._target_line is None:
|
||||||
|
return
|
||||||
|
self._panel.set_target_value(float(self._target_line.value()), precision=self._precision)
|
||||||
|
self._refresh_target_controls()
|
||||||
|
self.autoscale_requested.emit()
|
||||||
|
|
||||||
|
@SafeSlot()
|
||||||
|
def _on_target_move_requested(self):
|
||||||
|
if self._visible and self._positioner_name is not None and self._target_line is not None:
|
||||||
|
self.move_absolute_requested.emit(float(self._target_line.value()))
|
||||||
|
|
||||||
|
@SafeSlot(str)
|
||||||
|
def _on_fit_selection_changed(self, _curve_id: str):
|
||||||
|
self._refresh_fit_actions()
|
||||||
|
|
||||||
|
@SafeSlot(float)
|
||||||
|
def _on_fit_center_requested(self, value: float):
|
||||||
|
if self._visible and self._positioner_name is not None:
|
||||||
|
self.move_absolute_requested.emit(float(value))
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from qtpy.QtCore import Qt, Signal
|
||||||
|
from qtpy.QtGui import QColor
|
||||||
|
from qtpy.QtWidgets import (
|
||||||
|
QCheckBox,
|
||||||
|
QGridLayout,
|
||||||
|
QGroupBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QSizePolicy,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from bec_widgets.utils.colors import get_accent_colors, get_theme_name
|
||||||
|
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line import (
|
||||||
|
PositionerControlLine,
|
||||||
|
)
|
||||||
|
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
|
||||||
|
|
||||||
|
|
||||||
|
class WaveformAlignmentPanel(QWidget):
|
||||||
|
"""Compact bottom panel used by Waveform alignment mode."""
|
||||||
|
|
||||||
|
position_readback_changed = Signal(float)
|
||||||
|
target_toggled = Signal(bool)
|
||||||
|
target_move_requested = Signal()
|
||||||
|
fit_selection_changed = Signal(str)
|
||||||
|
fit_center_requested = Signal(float)
|
||||||
|
|
||||||
|
def __init__(self, parent=None, client=None, gui_id: str | None = None, **kwargs):
|
||||||
|
super().__init__(parent=parent, **kwargs)
|
||||||
|
self.setProperty("skip_settings", True)
|
||||||
|
|
||||||
|
self.positioner = PositionerControlLine(parent=self, client=client, gui_id=gui_id)
|
||||||
|
self.positioner.hide_device_selection = True
|
||||||
|
|
||||||
|
self.fit_dialog = LMFitDialog(
|
||||||
|
parent=self, client=client, gui_id=gui_id, ui_file="lmfit_dialog_compact.ui"
|
||||||
|
)
|
||||||
|
self.fit_dialog.active_action_list = ["center"]
|
||||||
|
self.fit_dialog.enable_actions = False
|
||||||
|
|
||||||
|
self.target_toggle = QCheckBox("Target: --", parent=self)
|
||||||
|
self.move_to_target_button = QPushButton("Move To Target", parent=self)
|
||||||
|
self.move_to_target_button.setEnabled(False)
|
||||||
|
self.target_group = QGroupBox("Target Position", parent=self)
|
||||||
|
|
||||||
|
self.status_label = QLabel(parent=self)
|
||||||
|
self.status_label.setWordWrap(False)
|
||||||
|
self.status_label.setAlignment(
|
||||||
|
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
|
||||||
|
)
|
||||||
|
self.status_label.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
|
||||||
|
self.status_label.setMaximumHeight(28)
|
||||||
|
self.status_label.setVisible(False)
|
||||||
|
|
||||||
|
self._init_ui()
|
||||||
|
self.fit_dialog.setMinimumHeight(0)
|
||||||
|
self.target_group.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||||
|
self._sync_target_group_size()
|
||||||
|
self.refresh_theme_colors()
|
||||||
|
self._connect_signals()
|
||||||
|
|
||||||
|
def _connect_signals(self):
|
||||||
|
self.positioner.position_update.connect(self.position_readback_changed)
|
||||||
|
self.target_toggle.toggled.connect(self.target_toggled)
|
||||||
|
self.move_to_target_button.clicked.connect(self.target_move_requested)
|
||||||
|
self.fit_dialog.selected_fit.connect(self.fit_selection_changed)
|
||||||
|
self.fit_dialog.move_action.connect(self._forward_fit_move_action)
|
||||||
|
|
||||||
|
def _init_ui(self):
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||||
|
self.setMinimumHeight(260)
|
||||||
|
|
||||||
|
root = QGridLayout(self)
|
||||||
|
root.setContentsMargins(8, 8, 8, 8)
|
||||||
|
root.setSpacing(8)
|
||||||
|
|
||||||
|
self.fit_dialog.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||||
|
root.addWidget(
|
||||||
|
self.status_label,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
alignment=Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter,
|
||||||
|
)
|
||||||
|
root.addWidget(self.fit_dialog, 1, 0, 1, 2)
|
||||||
|
|
||||||
|
target_layout = QHBoxLayout(self.target_group)
|
||||||
|
target_layout.addWidget(self.target_toggle)
|
||||||
|
target_layout.addWidget(self.move_to_target_button)
|
||||||
|
|
||||||
|
root.addWidget(self.positioner, 2, 0, alignment=Qt.AlignmentFlag.AlignTop)
|
||||||
|
root.addWidget(self.target_group, 2, 1, alignment=Qt.AlignmentFlag.AlignTop)
|
||||||
|
root.setColumnStretch(0, 1)
|
||||||
|
root.setColumnStretch(1, 0)
|
||||||
|
root.setRowStretch(1, 1)
|
||||||
|
|
||||||
|
def _sync_target_group_size(self):
|
||||||
|
representative_text = "Target: -99999.999"
|
||||||
|
label_width = max(
|
||||||
|
self.target_toggle.sizeHint().width(),
|
||||||
|
self.target_toggle.fontMetrics().horizontalAdvance(representative_text) + 24,
|
||||||
|
)
|
||||||
|
self.target_toggle.setMinimumWidth(label_width)
|
||||||
|
|
||||||
|
# To make those two box the same height
|
||||||
|
target_height = max(
|
||||||
|
self.positioner.height(),
|
||||||
|
self.positioner.ui.device_box.minimumSizeHint().height(),
|
||||||
|
self.positioner.ui.device_box.sizeHint().height(),
|
||||||
|
)
|
||||||
|
self.target_group.setFixedHeight(target_height)
|
||||||
|
self.target_group.setFixedWidth(self.target_group.sizeHint().width() + 16)
|
||||||
|
|
||||||
|
def showEvent(self, event):
|
||||||
|
super().showEvent(event)
|
||||||
|
self._sync_target_group_size()
|
||||||
|
|
||||||
|
def resizeEvent(self, event):
|
||||||
|
super().resizeEvent(event)
|
||||||
|
self._sync_target_group_size()
|
||||||
|
|
||||||
|
def set_status_message(self, text: str | None):
|
||||||
|
"""Show or hide the alignment status pill.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Message to display. Pass `None` or an empty string to hide the pill.
|
||||||
|
"""
|
||||||
|
|
||||||
|
text = text or ""
|
||||||
|
self.status_label.setText(text)
|
||||||
|
self.status_label.setVisible(bool(text))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _qcolor_to_rgba(color: QColor, alpha: int | None = None) -> str:
|
||||||
|
if alpha is not None:
|
||||||
|
color = QColor(color)
|
||||||
|
color.setAlpha(alpha)
|
||||||
|
return f"rgba({color.red()}, {color.green()}, {color.blue()}, {color.alpha()})"
|
||||||
|
|
||||||
|
def refresh_theme_colors(self):
|
||||||
|
"""Apply theme-aware accent styling to the status pill."""
|
||||||
|
warning = get_accent_colors().warning
|
||||||
|
is_light = get_theme_name() == "light"
|
||||||
|
text_color = "#202124" if is_light else warning.name()
|
||||||
|
fill_alpha = 72 if is_light else 48
|
||||||
|
border_alpha = 220 if is_light else 160
|
||||||
|
|
||||||
|
self.status_label.setStyleSheet(f"""
|
||||||
|
QLabel {{
|
||||||
|
background-color: {self._qcolor_to_rgba(warning, fill_alpha)};
|
||||||
|
border: 1px solid {self._qcolor_to_rgba(warning, border_alpha)};
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
color: {text_color};
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
|
||||||
|
def set_positioner_device(self, device: str | None):
|
||||||
|
"""Bind the embedded positioner control to a fixed device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device: Name of the positioner device to display, or `None` to clear it.
|
||||||
|
"""
|
||||||
|
if device is None:
|
||||||
|
self.positioner.ui.device_box.setTitle("No positioner selected")
|
||||||
|
return
|
||||||
|
if self.positioner.device != device:
|
||||||
|
self.positioner.set_positioner(device)
|
||||||
|
self.positioner.hide_device_selection = True
|
||||||
|
|
||||||
|
def set_positioner_enabled(self, enabled: bool):
|
||||||
|
"""Enable or disable the embedded positioner widget.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
enabled: Whether the positioner widget should accept interaction.
|
||||||
|
"""
|
||||||
|
self.positioner.setEnabled(enabled)
|
||||||
|
|
||||||
|
def force_positioner_readback(self):
|
||||||
|
"""Trigger an immediate readback refresh on the embedded positioner widget."""
|
||||||
|
self.positioner.force_update_readback()
|
||||||
|
|
||||||
|
def set_target_enabled(self, enabled: bool):
|
||||||
|
"""Enable or disable the target-line toggle.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
enabled: Whether the target toggle should accept interaction.
|
||||||
|
"""
|
||||||
|
self.target_toggle.setEnabled(enabled)
|
||||||
|
|
||||||
|
def set_target_move_enabled(self, enabled: bool):
|
||||||
|
"""Enable or disable the move-to-target button.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
enabled: Whether the move button should accept interaction.
|
||||||
|
"""
|
||||||
|
self.move_to_target_button.setEnabled(enabled)
|
||||||
|
|
||||||
|
def set_target_active(self, active: bool):
|
||||||
|
"""Programmatically toggle the draggable target-line state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
active: Whether the target line should be considered active.
|
||||||
|
"""
|
||||||
|
blocker = self.target_toggle.blockSignals(True)
|
||||||
|
self.target_toggle.setChecked(active)
|
||||||
|
self.target_toggle.blockSignals(blocker)
|
||||||
|
if not active:
|
||||||
|
self.set_target_value(None)
|
||||||
|
|
||||||
|
def set_target_value(self, value: float | None, precision: int = 3) -> None:
|
||||||
|
"""
|
||||||
|
Update the target checkbox label for the draggable target line.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value(float | None): The target value to display. If None, the label will show "--".
|
||||||
|
precision(int): The number of decimal places to display for the target value.
|
||||||
|
"""
|
||||||
|
if value is None or not self.target_toggle.isChecked():
|
||||||
|
self.target_toggle.setText("Target: --")
|
||||||
|
return
|
||||||
|
self.target_toggle.setText(f"Target: {value:.{precision}f}")
|
||||||
|
|
||||||
|
def set_fit_actions_enabled(self, enabled: bool):
|
||||||
|
"""Enable or disable LMFit action buttons in the embedded fit dialog.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
enabled: Whether fit action buttons should be enabled.
|
||||||
|
"""
|
||||||
|
self.fit_dialog.enable_actions = enabled
|
||||||
|
|
||||||
|
def update_dap_summary(self, data: dict, metadata: dict):
|
||||||
|
"""Forward a DAP summary update into the embedded fit dialog.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: DAP fit summary payload.
|
||||||
|
metadata: Metadata describing the emitting DAP curve.
|
||||||
|
"""
|
||||||
|
self.fit_dialog.update_summary_tree(data, metadata)
|
||||||
|
|
||||||
|
def remove_dap_curve(self, curve_id: str):
|
||||||
|
"""Remove DAP summary state for a deleted fit curve.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
curve_id: Label of the DAP curve that should be removed.
|
||||||
|
"""
|
||||||
|
self.fit_dialog.remove_dap_data(curve_id)
|
||||||
|
|
||||||
|
def clear_fit_selection_if_missing(self):
|
||||||
|
"""Select a remaining fit curve if the current selection no longer exists."""
|
||||||
|
fit_curve_id = self.fit_dialog.fit_curve_id
|
||||||
|
if fit_curve_id is not None and fit_curve_id not in self.fit_dialog.summary_data:
|
||||||
|
remaining = list(self.fit_dialog.summary_data)
|
||||||
|
self.fit_dialog.fit_curve_id = remaining[0] if remaining else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_active(self) -> bool:
|
||||||
|
"""Whether the target-line checkbox is currently checked."""
|
||||||
|
return self.target_toggle.isChecked()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def selected_fit_curve_id(self) -> str | None:
|
||||||
|
"""Return the currently selected fit curve label, if any."""
|
||||||
|
return self.fit_dialog.fit_curve_id
|
||||||
|
|
||||||
|
def selected_fit_summary(self) -> dict | None:
|
||||||
|
"""Return the summary payload for the currently selected fit curve.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The selected fit summary, or `None` if no fit curve is selected.
|
||||||
|
"""
|
||||||
|
fit_curve_id = self.selected_fit_curve_id
|
||||||
|
if fit_curve_id is None:
|
||||||
|
return None
|
||||||
|
return self.fit_dialog.summary_data.get(fit_curve_id)
|
||||||
|
|
||||||
|
def _forward_fit_move_action(self, action: tuple[str, float]):
|
||||||
|
param_name, param_value = action
|
||||||
|
if param_name == "center":
|
||||||
|
self.fit_center_requested.emit(float(param_value))
|
||||||
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Literal
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
from bec_lib import bec_logger, messages
|
from bec_lib import bec_logger, messages
|
||||||
|
from bec_lib.device import Positioner
|
||||||
from bec_lib.endpoints import MessageEndpoints
|
from bec_lib.endpoints import MessageEndpoints
|
||||||
from bec_lib.lmfit_serializer import serialize_lmfit_params, serialize_param_object
|
from bec_lib.lmfit_serializer import serialize_lmfit_params, serialize_param_object
|
||||||
from bec_lib.scan_data_container import ScanDataContainer
|
from bec_lib.scan_data_container import ScanDataContainer
|
||||||
@@ -24,17 +25,24 @@ from qtpy.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from bec_widgets.utils import ConnectionConfig
|
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||||
from bec_widgets.utils.bec_signal_proxy import BECSignalProxy
|
from bec_widgets.utils.bec_signal_proxy import BECSignalProxy
|
||||||
from bec_widgets.utils.colors import Colors, apply_theme
|
from bec_widgets.utils.colors import Colors, apply_theme
|
||||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||||
|
from bec_widgets.utils.side_panel import SidePanel
|
||||||
|
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
||||||
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
|
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
|
||||||
from bec_widgets.widgets.plots.plot_base import PlotBase
|
from bec_widgets.widgets.plots.plot_base import PlotBase
|
||||||
from bec_widgets.widgets.plots.waveform.curve import Curve, CurveConfig, DeviceSignal
|
from bec_widgets.widgets.plots.waveform.curve import Curve, CurveConfig, DeviceSignal
|
||||||
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_setting import CurveSetting
|
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_setting import CurveSetting
|
||||||
|
from bec_widgets.widgets.plots.waveform.utils.alignment_controller import (
|
||||||
|
AlignmentContext,
|
||||||
|
WaveformAlignmentController,
|
||||||
|
)
|
||||||
|
from bec_widgets.widgets.plots.waveform.utils.alignment_panel import WaveformAlignmentPanel
|
||||||
from bec_widgets.widgets.plots.waveform.utils.roi_manager import WaveformROIManager
|
from bec_widgets.widgets.plots.waveform.utils.roi_manager import WaveformROIManager
|
||||||
from bec_widgets.widgets.services.scan_history_browser.scan_history_browser import (
|
from bec_widgets.widgets.services.scan_history_browser.scan_history_browser import (
|
||||||
ScanHistoryBrowser,
|
ScanHistoryBrowser,
|
||||||
@@ -156,6 +164,12 @@ class Waveform(PlotBase):
|
|||||||
"label_suffix": "",
|
"label_suffix": "",
|
||||||
}
|
}
|
||||||
self._current_x_device: tuple[str, str] | None = None
|
self._current_x_device: tuple[str, str] | None = None
|
||||||
|
self._alignment_panel_visible = False
|
||||||
|
self._alignment_side_panel: SidePanel | None = None
|
||||||
|
self._alignment_panel_index: int | None = None
|
||||||
|
self._alignment_panel: WaveformAlignmentPanel | None = None
|
||||||
|
self._alignment_controller: WaveformAlignmentController | None = None
|
||||||
|
self._alignment_positioner_name: str | None = None
|
||||||
|
|
||||||
# Specific GUI elements
|
# Specific GUI elements
|
||||||
self._init_roi_manager()
|
self._init_roi_manager()
|
||||||
@@ -165,6 +179,7 @@ class Waveform(PlotBase):
|
|||||||
self._add_waveform_specific_popup()
|
self._add_waveform_specific_popup()
|
||||||
self._enable_roi_toolbar_action(False) # default state where are no dap curves
|
self._enable_roi_toolbar_action(False) # default state where are no dap curves
|
||||||
self._init_curve_dialog()
|
self._init_curve_dialog()
|
||||||
|
self._init_alignment_mode()
|
||||||
self.curve_settings_dialog = None
|
self.curve_settings_dialog = None
|
||||||
|
|
||||||
# Large‑dataset guard
|
# Large‑dataset guard
|
||||||
@@ -195,7 +210,9 @@ class Waveform(PlotBase):
|
|||||||
# To fix the ViewAll action with clipToView activated
|
# To fix the ViewAll action with clipToView activated
|
||||||
self._connect_viewbox_menu_actions()
|
self._connect_viewbox_menu_actions()
|
||||||
|
|
||||||
self.toolbar.show_bundles(["plot_export", "mouse_interaction", "roi", "axis_popup"])
|
self.toolbar.show_bundles(
|
||||||
|
["plot_export", "mouse_interaction", "roi", "alignment_mode", "axis_popup"]
|
||||||
|
)
|
||||||
|
|
||||||
def _connect_viewbox_menu_actions(self):
|
def _connect_viewbox_menu_actions(self):
|
||||||
"""Connect the viewbox menu action ViewAll to the custom reset_view method."""
|
"""Connect the viewbox menu action ViewAll to the custom reset_view method."""
|
||||||
@@ -221,6 +238,12 @@ class Waveform(PlotBase):
|
|||||||
theme(str, optional): The theme to be applied.
|
theme(str, optional): The theme to be applied.
|
||||||
"""
|
"""
|
||||||
self._refresh_colors()
|
self._refresh_colors()
|
||||||
|
alignment_panel = getattr(self, "_alignment_panel", None)
|
||||||
|
alignment_controller = getattr(self, "_alignment_controller", None)
|
||||||
|
if alignment_panel is not None:
|
||||||
|
alignment_panel.refresh_theme_colors()
|
||||||
|
if alignment_controller is not None:
|
||||||
|
alignment_controller.refresh_theme_colors()
|
||||||
super().apply_theme(theme)
|
super().apply_theme(theme)
|
||||||
|
|
||||||
def add_side_menus(self):
|
def add_side_menus(self):
|
||||||
@@ -230,6 +253,159 @@ class Waveform(PlotBase):
|
|||||||
super().add_side_menus()
|
super().add_side_menus()
|
||||||
self._add_dap_summary_side_menu()
|
self._add_dap_summary_side_menu()
|
||||||
|
|
||||||
|
def _init_alignment_mode(self):
|
||||||
|
"""
|
||||||
|
Initialize the top alignment panel.
|
||||||
|
"""
|
||||||
|
self.toolbar.components.add_safe(
|
||||||
|
"alignment_mode",
|
||||||
|
MaterialIconAction(
|
||||||
|
icon_name="align_horizontal_center",
|
||||||
|
tooltip="Show Alignment Mode",
|
||||||
|
checkable=True,
|
||||||
|
parent=self,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
bundle = ToolbarBundle("alignment_mode", self.toolbar.components)
|
||||||
|
bundle.add_action("alignment_mode")
|
||||||
|
self.toolbar.add_bundle(bundle)
|
||||||
|
shown_bundles = list(self.toolbar.shown_bundles)
|
||||||
|
if "alignment_mode" not in shown_bundles:
|
||||||
|
shown_bundles.append("alignment_mode")
|
||||||
|
self.toolbar.show_bundles(shown_bundles)
|
||||||
|
|
||||||
|
self._alignment_side_panel = SidePanel(
|
||||||
|
parent=self, orientation="top", panel_max_width=320, show_toolbar=False
|
||||||
|
)
|
||||||
|
self.layout_manager.add_widget_relative(
|
||||||
|
self._alignment_side_panel,
|
||||||
|
self.round_plot_widget,
|
||||||
|
position="top",
|
||||||
|
shift_direction="down",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._alignment_panel = WaveformAlignmentPanel(parent=self, client=self.client)
|
||||||
|
self._alignment_controller = WaveformAlignmentController(
|
||||||
|
self.plot_item, self._alignment_panel, parent=self
|
||||||
|
)
|
||||||
|
self._alignment_panel_index = self._alignment_side_panel.add_menu(
|
||||||
|
widget=self._alignment_panel
|
||||||
|
)
|
||||||
|
self._alignment_controller.move_absolute_requested.connect(self._move_alignment_positioner)
|
||||||
|
self._alignment_controller.autoscale_requested.connect(self._autoscale_alignment_indicators)
|
||||||
|
self.dap_summary_update.connect(self._alignment_controller.update_dap_summary)
|
||||||
|
self.toolbar.components.get_action("alignment_mode").action.toggled.connect(
|
||||||
|
self.toggle_alignment_mode
|
||||||
|
)
|
||||||
|
|
||||||
|
self._refresh_alignment_state()
|
||||||
|
|
||||||
|
@SafeSlot(bool)
|
||||||
|
def toggle_alignment_mode(self, checked: bool):
|
||||||
|
"""
|
||||||
|
Show or hide the alignment panel.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
checked(bool): Whether the panel should be visible.
|
||||||
|
"""
|
||||||
|
if self._alignment_side_panel is None or self._alignment_panel_index is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._alignment_panel_visible = checked
|
||||||
|
if checked:
|
||||||
|
self._alignment_side_panel.show_panel(self._alignment_panel_index)
|
||||||
|
self._refresh_alignment_state(force_readback=True)
|
||||||
|
self._refresh_dap_signals()
|
||||||
|
else:
|
||||||
|
self._alignment_side_panel.hide_panel()
|
||||||
|
self._refresh_alignment_state()
|
||||||
|
|
||||||
|
def _refresh_alignment_state(self, force_readback: bool = False):
|
||||||
|
"""
|
||||||
|
Refresh the alignment panel state after waveform changes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
force_readback(bool): Force a positioner readback refresh.
|
||||||
|
"""
|
||||||
|
if self._alignment_controller is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
context = self._build_alignment_context(force_readback=force_readback)
|
||||||
|
self._alignment_positioner_name = context.positioner_name
|
||||||
|
self._alignment_controller.update_context(context)
|
||||||
|
|
||||||
|
def _resolve_alignment_positioner(self) -> str | None:
|
||||||
|
"""
|
||||||
|
Resolve the active x-axis positioner for alignment mode.
|
||||||
|
"""
|
||||||
|
if self.x_axis_mode["name"] in {"index", "timestamp"}:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.x_axis_mode["name"] == "auto":
|
||||||
|
device_name = self._current_x_device[0] if self._current_x_device is not None else None
|
||||||
|
else:
|
||||||
|
device_name = self.x_axis_mode["name"]
|
||||||
|
|
||||||
|
if not device_name or device_name not in self.dev:
|
||||||
|
return None
|
||||||
|
if not isinstance(self.dev[device_name], Positioner):
|
||||||
|
return None
|
||||||
|
return device_name
|
||||||
|
|
||||||
|
def _build_alignment_context(self, force_readback: bool = False) -> AlignmentContext:
|
||||||
|
"""Build controller-facing alignment context from waveform/device state."""
|
||||||
|
positioner_name = self._resolve_alignment_positioner()
|
||||||
|
if positioner_name is None:
|
||||||
|
return AlignmentContext(
|
||||||
|
visible=self._alignment_panel_visible,
|
||||||
|
positioner_name=None,
|
||||||
|
has_dap_curves=bool(self._dap_curves),
|
||||||
|
force_readback=force_readback,
|
||||||
|
)
|
||||||
|
|
||||||
|
precision = getattr(self.dev[positioner_name], "precision", 3)
|
||||||
|
try:
|
||||||
|
precision = int(precision)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
precision = 3
|
||||||
|
|
||||||
|
limits = getattr(self.dev[positioner_name], "limits", None)
|
||||||
|
parsed_limits: tuple[float, float] | None = None
|
||||||
|
if limits is not None and len(limits) == 2:
|
||||||
|
low, high = float(limits[0]), float(limits[1])
|
||||||
|
if low != 0 or high != 0:
|
||||||
|
if low > high:
|
||||||
|
low, high = high, low
|
||||||
|
parsed_limits = (low, high)
|
||||||
|
|
||||||
|
data = self.dev[positioner_name].read(cached=True)
|
||||||
|
value = data.get(positioner_name, {}).get("value")
|
||||||
|
readback = None if value is None else float(value)
|
||||||
|
|
||||||
|
return AlignmentContext(
|
||||||
|
visible=self._alignment_panel_visible,
|
||||||
|
positioner_name=positioner_name,
|
||||||
|
precision=precision,
|
||||||
|
limits=parsed_limits,
|
||||||
|
readback=readback,
|
||||||
|
has_dap_curves=bool(self._dap_curves),
|
||||||
|
force_readback=force_readback,
|
||||||
|
)
|
||||||
|
|
||||||
|
@SafeSlot(float)
|
||||||
|
def _move_alignment_positioner(self, value: float):
|
||||||
|
"""
|
||||||
|
Move the active alignment positioner to an absolute value requested by the controller.
|
||||||
|
"""
|
||||||
|
if self._alignment_positioner_name is None:
|
||||||
|
return
|
||||||
|
self.dev[self._alignment_positioner_name].move(float(value), relative=False)
|
||||||
|
|
||||||
|
@SafeSlot()
|
||||||
|
def _autoscale_alignment_indicators(self):
|
||||||
|
"""Autoscale the waveform view after alignment indicator updates."""
|
||||||
|
self._reset_view()
|
||||||
|
|
||||||
def _add_waveform_specific_popup(self):
|
def _add_waveform_specific_popup(self):
|
||||||
"""
|
"""
|
||||||
Add popups to the Waveform widget.
|
Add popups to the Waveform widget.
|
||||||
@@ -266,7 +442,7 @@ class Waveform(PlotBase):
|
|||||||
Due to setting clipToView to True on the curves, the autoRange() method
|
Due to setting clipToView to True on the curves, the autoRange() method
|
||||||
of the ViewBox does no longer work as expected. This method deactivates the
|
of the ViewBox does no longer work as expected. This method deactivates the
|
||||||
setClipToView for all curves, calls autoRange() to circumvent that issue.
|
setClipToView for all curves, calls autoRange() to circumvent that issue.
|
||||||
Afterwards, it re-enables the setClipToView for all curves again.
|
Afterward, it re-enables the setClipToView for all curves again.
|
||||||
|
|
||||||
It is hooked to the ViewAll action in the right-click menu of the pg.PlotItem ViewBox.
|
It is hooked to the ViewAll action in the right-click menu of the pg.PlotItem ViewBox.
|
||||||
"""
|
"""
|
||||||
@@ -544,6 +720,7 @@ class Waveform(PlotBase):
|
|||||||
self.sync_signal_update.emit()
|
self.sync_signal_update.emit()
|
||||||
self.plot_item.enableAutoRange(x=True)
|
self.plot_item.enableAutoRange(x=True)
|
||||||
self.round_plot_widget.apply_plot_widget_style() # To keep the correct theme
|
self.round_plot_widget.apply_plot_widget_style() # To keep the correct theme
|
||||||
|
self._refresh_alignment_state(force_readback=True)
|
||||||
|
|
||||||
@SafeProperty(str)
|
@SafeProperty(str)
|
||||||
def signal_x(self) -> str | None:
|
def signal_x(self) -> str | None:
|
||||||
@@ -573,6 +750,7 @@ class Waveform(PlotBase):
|
|||||||
self.sync_signal_update.emit()
|
self.sync_signal_update.emit()
|
||||||
self.plot_item.enableAutoRange(x=True)
|
self.plot_item.enableAutoRange(x=True)
|
||||||
self.round_plot_widget.apply_plot_widget_style()
|
self.round_plot_widget.apply_plot_widget_style()
|
||||||
|
self._refresh_alignment_state(force_readback=True)
|
||||||
|
|
||||||
@SafeProperty(str)
|
@SafeProperty(str)
|
||||||
def color_palette(self) -> str:
|
def color_palette(self) -> str:
|
||||||
@@ -627,6 +805,8 @@ class Waveform(PlotBase):
|
|||||||
continue
|
continue
|
||||||
config = CurveConfig(**cfg_dict)
|
config = CurveConfig(**cfg_dict)
|
||||||
self._add_curve(config=config)
|
self._add_curve(config=config)
|
||||||
|
self._refresh_alignment_state(force_readback=self._alignment_panel_visible)
|
||||||
|
self._refresh_dap_signals()
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
logger.error(f"Failed to decode JSON: {e}")
|
logger.error(f"Failed to decode JSON: {e}")
|
||||||
|
|
||||||
@@ -1002,6 +1182,7 @@ class Waveform(PlotBase):
|
|||||||
QTimer.singleShot(
|
QTimer.singleShot(
|
||||||
150, self.auto_range
|
150, self.auto_range
|
||||||
) # autorange with a delay to ensure the plot is updated
|
) # autorange with a delay to ensure the plot is updated
|
||||||
|
self._refresh_alignment_state()
|
||||||
|
|
||||||
return curve
|
return curve
|
||||||
|
|
||||||
@@ -1257,6 +1438,7 @@ class Waveform(PlotBase):
|
|||||||
self.remove_curve(curve.name())
|
self.remove_curve(curve.name())
|
||||||
if self.crosshair is not None:
|
if self.crosshair is not None:
|
||||||
self.crosshair.clear_markers()
|
self.crosshair.clear_markers()
|
||||||
|
self._refresh_alignment_state()
|
||||||
|
|
||||||
def get_curve(self, curve: int | str) -> Curve | None:
|
def get_curve(self, curve: int | str) -> Curve | None:
|
||||||
"""
|
"""
|
||||||
@@ -1292,6 +1474,7 @@ class Waveform(PlotBase):
|
|||||||
|
|
||||||
self._refresh_colors()
|
self._refresh_colors()
|
||||||
self._categorise_device_curves()
|
self._categorise_device_curves()
|
||||||
|
self._refresh_alignment_state()
|
||||||
|
|
||||||
def _remove_curve_by_name(self, name: str):
|
def _remove_curve_by_name(self, name: str):
|
||||||
"""
|
"""
|
||||||
@@ -1342,6 +1525,8 @@ class Waveform(PlotBase):
|
|||||||
and self.enable_side_panel is True
|
and self.enable_side_panel is True
|
||||||
):
|
):
|
||||||
self.dap_summary.remove_dap_data(curve.name())
|
self.dap_summary.remove_dap_data(curve.name())
|
||||||
|
if curve.config.source == "dap" and self._alignment_controller is not None:
|
||||||
|
self._alignment_controller.remove_dap_curve(curve.name())
|
||||||
|
|
||||||
# find a corresponding dap curve and remove it
|
# find a corresponding dap curve and remove it
|
||||||
for c in self.curves:
|
for c in self.curves:
|
||||||
@@ -1778,7 +1963,7 @@ class Waveform(PlotBase):
|
|||||||
if parent_curve is None:
|
if parent_curve is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"No device curve found for DAP curve '{dap_curve.name()}'!"
|
f"No device curve found for DAP curve '{dap_curve.name()}'!"
|
||||||
) # TODO triggerd when DAP curve is removed from the curve dialog, why?
|
) # TODO triggered when DAP curve is removed from the curve dialog, why?
|
||||||
continue
|
continue
|
||||||
|
|
||||||
x_data, y_data = parent_curve.get_data()
|
x_data, y_data = parent_curve.get_data()
|
||||||
@@ -1983,6 +2168,7 @@ class Waveform(PlotBase):
|
|||||||
"""
|
"""
|
||||||
x_data = None
|
x_data = None
|
||||||
new_suffix = None
|
new_suffix = None
|
||||||
|
previous_x_device = self._current_x_device
|
||||||
data, access_key = self._fetch_scan_data_and_access()
|
data, access_key = self._fetch_scan_data_and_access()
|
||||||
|
|
||||||
# 1 User wants custom signal
|
# 1 User wants custom signal
|
||||||
@@ -2041,6 +2227,7 @@ class Waveform(PlotBase):
|
|||||||
if not scan_report_devices:
|
if not scan_report_devices:
|
||||||
x_data = None
|
x_data = None
|
||||||
new_suffix = " (auto: index)"
|
new_suffix = " (auto: index)"
|
||||||
|
self._current_x_device = None
|
||||||
else:
|
else:
|
||||||
device_x = scan_report_devices[0]
|
device_x = scan_report_devices[0]
|
||||||
signal_x = self.entry_validator.validate_signal(device_x, None)
|
signal_x = self.entry_validator.validate_signal(device_x, None)
|
||||||
@@ -2050,8 +2237,10 @@ class Waveform(PlotBase):
|
|||||||
entry_obj = data.get(device_x, {}).get(signal_x)
|
entry_obj = data.get(device_x, {}).get(signal_x)
|
||||||
x_data = entry_obj.read()["value"] if entry_obj else None
|
x_data = entry_obj.read()["value"] if entry_obj else None
|
||||||
new_suffix = f" (auto: {device_x}-{signal_x})"
|
new_suffix = f" (auto: {device_x}-{signal_x})"
|
||||||
self._current_x_device = (device_x, signal_x)
|
self._current_x_device = (device_x, signal_x)
|
||||||
self._update_x_label_suffix(new_suffix)
|
self._update_x_label_suffix(new_suffix)
|
||||||
|
if previous_x_device != self._current_x_device:
|
||||||
|
self._refresh_alignment_state(force_readback=True)
|
||||||
return x_data
|
return x_data
|
||||||
|
|
||||||
def _update_x_label_suffix(self, new_suffix: str):
|
def _update_x_label_suffix(self, new_suffix: str):
|
||||||
@@ -2096,7 +2285,7 @@ class Waveform(PlotBase):
|
|||||||
|
|
||||||
def _categorise_device_curves(self) -> str:
|
def _categorise_device_curves(self) -> str:
|
||||||
"""
|
"""
|
||||||
Categorise the device curves into sync and async based on the readout priority.
|
Categorize the device curves into sync and async based on the readout priority.
|
||||||
"""
|
"""
|
||||||
if self.scan_item is None:
|
if self.scan_item is None:
|
||||||
self.update_with_scan_history(-1)
|
self.update_with_scan_history(-1)
|
||||||
@@ -2453,6 +2642,8 @@ class Waveform(PlotBase):
|
|||||||
Cleanup the widget by disconnecting signals and closing dialogs.
|
Cleanup the widget by disconnecting signals and closing dialogs.
|
||||||
"""
|
"""
|
||||||
self.proxy_dap_request.cleanup()
|
self.proxy_dap_request.cleanup()
|
||||||
|
if self._alignment_controller is not None:
|
||||||
|
self._alignment_controller.cleanup()
|
||||||
self.clear_all()
|
self.clear_all()
|
||||||
if self.curve_settings_dialog is not None:
|
if self.curve_settings_dialog is not None:
|
||||||
self.curve_settings_dialog.reject()
|
self.curve_settings_dialog.reject()
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ from qtpy import QtCore, QtGui
|
|||||||
from qtpy.QtGui import QColor
|
from qtpy.QtGui import QColor
|
||||||
from qtpy.QtWidgets import QWidget
|
from qtpy.QtWidgets import QWidget
|
||||||
|
|
||||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
from bec_widgets import BECWidget
|
||||||
|
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||||
from bec_widgets.utils.colors import Colors
|
from bec_widgets.utils.colors import Colors
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ class ProgressbarConfig(ConnectionConfig):
|
|||||||
line_width: int = Field(20, description="Line widths for the progress bars.")
|
line_width: int = Field(20, description="Line widths for the progress bars.")
|
||||||
start_position: int = Field(
|
start_position: int = Field(
|
||||||
90,
|
90,
|
||||||
description="Start position for the progress bars in degrees. Default is 90 degrees - corespons to "
|
description="Start position for the progress bars in degrees. Default is 90 degrees - corresponds to "
|
||||||
"the top of the ring.",
|
"the top of the ring.",
|
||||||
)
|
)
|
||||||
min_value: int | float = Field(0, description="Minimum value for the progress bars.")
|
min_value: int | float = Field(0, description="Minimum value for the progress bars.")
|
||||||
@@ -59,7 +60,7 @@ class ProgressbarConfig(ConnectionConfig):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Ring(BECConnector, QWidget):
|
class Ring(BECWidget, QWidget):
|
||||||
USER_ACCESS = [
|
USER_ACCESS = [
|
||||||
"set_value",
|
"set_value",
|
||||||
"set_color",
|
"set_color",
|
||||||
@@ -82,8 +83,26 @@ class Ring(BECConnector, QWidget):
|
|||||||
self.registered_slot: tuple[Callable, str | EndpointInfo] | None = None
|
self.registered_slot: tuple[Callable, str | EndpointInfo] | None = None
|
||||||
self.RID = None
|
self.RID = None
|
||||||
self._gap = 5
|
self._gap = 5
|
||||||
|
self._hovered = False
|
||||||
|
self._hover_progress = 0.0
|
||||||
|
self._hover_animation = QtCore.QPropertyAnimation(self, b"hover_progress", parent=self)
|
||||||
|
self._hover_animation.setDuration(180)
|
||||||
|
easing_curve = (
|
||||||
|
QtCore.QEasingCurve.Type.OutCubic
|
||||||
|
if hasattr(QtCore.QEasingCurve, "Type")
|
||||||
|
else QtCore.QEasingCurve.Type.OutCubic
|
||||||
|
)
|
||||||
|
self._hover_animation.setEasingCurve(easing_curve)
|
||||||
self.set_start_angle(self.config.start_position)
|
self.set_start_angle(self.config.start_position)
|
||||||
|
|
||||||
|
def _request_update(self, *, refresh_tooltip: bool = True):
|
||||||
|
# NOTE why not just overwrite update() to always refresh the tooltip?
|
||||||
|
# Because in some cases (e.g. hover animation) we want to update the widget without refreshing the tooltip, to avoid performance issues.
|
||||||
|
if refresh_tooltip:
|
||||||
|
if self.progress_container and self.progress_container.is_ring_hovered(self):
|
||||||
|
self.progress_container.refresh_hover_tooltip(self)
|
||||||
|
self.update()
|
||||||
|
|
||||||
def set_value(self, value: int | float):
|
def set_value(self, value: int | float):
|
||||||
"""
|
"""
|
||||||
Set the value for the ring widget
|
Set the value for the ring widget
|
||||||
@@ -107,7 +126,7 @@ class Ring(BECConnector, QWidget):
|
|||||||
if self.config.link_colors:
|
if self.config.link_colors:
|
||||||
self._auto_set_background_color()
|
self._auto_set_background_color()
|
||||||
|
|
||||||
self.update()
|
self._request_update()
|
||||||
|
|
||||||
def set_background(self, color: str | tuple | QColor):
|
def set_background(self, color: str | tuple | QColor):
|
||||||
"""
|
"""
|
||||||
@@ -122,7 +141,7 @@ class Ring(BECConnector, QWidget):
|
|||||||
|
|
||||||
self._background_color = self.convert_color(color)
|
self._background_color = self.convert_color(color)
|
||||||
self.config.background_color = self._background_color.name()
|
self.config.background_color = self._background_color.name()
|
||||||
self.update()
|
self._request_update()
|
||||||
|
|
||||||
def _auto_set_background_color(self):
|
def _auto_set_background_color(self):
|
||||||
"""
|
"""
|
||||||
@@ -133,7 +152,7 @@ class Ring(BECConnector, QWidget):
|
|||||||
bg_color = Colors.subtle_background_color(self._color, bg)
|
bg_color = Colors.subtle_background_color(self._color, bg)
|
||||||
self.config.background_color = bg_color.name()
|
self.config.background_color = bg_color.name()
|
||||||
self._background_color = bg_color
|
self._background_color = bg_color
|
||||||
self.update()
|
self._request_update()
|
||||||
|
|
||||||
def set_colors_linked(self, linked: bool):
|
def set_colors_linked(self, linked: bool):
|
||||||
"""
|
"""
|
||||||
@@ -146,7 +165,7 @@ class Ring(BECConnector, QWidget):
|
|||||||
self.config.link_colors = linked
|
self.config.link_colors = linked
|
||||||
if linked:
|
if linked:
|
||||||
self._auto_set_background_color()
|
self._auto_set_background_color()
|
||||||
self.update()
|
self._request_update()
|
||||||
|
|
||||||
def set_line_width(self, width: int):
|
def set_line_width(self, width: int):
|
||||||
"""
|
"""
|
||||||
@@ -156,7 +175,7 @@ class Ring(BECConnector, QWidget):
|
|||||||
width(int): Line width for the ring widget
|
width(int): Line width for the ring widget
|
||||||
"""
|
"""
|
||||||
self.config.line_width = width
|
self.config.line_width = width
|
||||||
self.update()
|
self._request_update()
|
||||||
|
|
||||||
def set_min_max_values(self, min_value: int | float, max_value: int | float):
|
def set_min_max_values(self, min_value: int | float, max_value: int | float):
|
||||||
"""
|
"""
|
||||||
@@ -168,7 +187,7 @@ class Ring(BECConnector, QWidget):
|
|||||||
"""
|
"""
|
||||||
self.config.min_value = min_value
|
self.config.min_value = min_value
|
||||||
self.config.max_value = max_value
|
self.config.max_value = max_value
|
||||||
self.update()
|
self._request_update()
|
||||||
|
|
||||||
def set_start_angle(self, start_angle: int):
|
def set_start_angle(self, start_angle: int):
|
||||||
"""
|
"""
|
||||||
@@ -178,7 +197,7 @@ class Ring(BECConnector, QWidget):
|
|||||||
start_angle(int): Start angle for the ring widget in degrees
|
start_angle(int): Start angle for the ring widget in degrees
|
||||||
"""
|
"""
|
||||||
self.config.start_position = start_angle
|
self.config.start_position = start_angle
|
||||||
self.update()
|
self._request_update()
|
||||||
|
|
||||||
def set_update(
|
def set_update(
|
||||||
self, mode: Literal["manual", "scan", "device"], device: str = "", signal: str = ""
|
self, mode: Literal["manual", "scan", "device"], device: str = "", signal: str = ""
|
||||||
@@ -237,7 +256,7 @@ class Ring(BECConnector, QWidget):
|
|||||||
precision(int): Precision for the ring widget
|
precision(int): Precision for the ring widget
|
||||||
"""
|
"""
|
||||||
self.config.precision = precision
|
self.config.precision = precision
|
||||||
self.update()
|
self._request_update()
|
||||||
|
|
||||||
def set_direction(self, direction: int):
|
def set_direction(self, direction: int):
|
||||||
"""
|
"""
|
||||||
@@ -247,7 +266,7 @@ class Ring(BECConnector, QWidget):
|
|||||||
direction(int): Direction for the ring widget. -1 for clockwise, 1 for counter-clockwise.
|
direction(int): Direction for the ring widget. -1 for clockwise, 1 for counter-clockwise.
|
||||||
"""
|
"""
|
||||||
self.config.direction = direction
|
self.config.direction = direction
|
||||||
self.update()
|
self._request_update()
|
||||||
|
|
||||||
def _get_signals_for_device(self, device: str) -> dict[str, list[str]]:
|
def _get_signals_for_device(self, device: str) -> dict[str, list[str]]:
|
||||||
"""
|
"""
|
||||||
@@ -276,7 +295,7 @@ class Ring(BECConnector, QWidget):
|
|||||||
for obj in dev_obj._info["signals"].values()
|
for obj in dev_obj._info["signals"].values()
|
||||||
if obj["kind_str"] == "hinted"
|
if obj["kind_str"] == "hinted"
|
||||||
and obj["signal_class"]
|
and obj["signal_class"]
|
||||||
not in ["ProgressSignal", "AyncSignal", "AsyncMultiSignal", "DynamicSignal"]
|
not in ["ProgressSignal", "AsyncSignal", "AsyncMultiSignal", "DynamicSignal"]
|
||||||
]
|
]
|
||||||
|
|
||||||
normal_signals = [
|
normal_signals = [
|
||||||
@@ -424,8 +443,11 @@ class Ring(BECConnector, QWidget):
|
|||||||
rect.adjust(max_ring_size, max_ring_size, -max_ring_size, -max_ring_size)
|
rect.adjust(max_ring_size, max_ring_size, -max_ring_size, -max_ring_size)
|
||||||
|
|
||||||
# Background arc
|
# Background arc
|
||||||
|
base_line_width = float(self.config.line_width)
|
||||||
|
hover_line_delta = min(3.0, round(base_line_width * 0.6, 1))
|
||||||
|
current_line_width = base_line_width + (hover_line_delta * self._hover_progress)
|
||||||
painter.setPen(
|
painter.setPen(
|
||||||
QtGui.QPen(self._background_color, self.config.line_width, QtCore.Qt.PenStyle.SolidLine)
|
QtGui.QPen(self._background_color, current_line_width, QtCore.Qt.PenStyle.SolidLine)
|
||||||
)
|
)
|
||||||
|
|
||||||
gap: int = self.gap # type: ignore
|
gap: int = self.gap # type: ignore
|
||||||
@@ -433,13 +455,25 @@ class Ring(BECConnector, QWidget):
|
|||||||
# Important: Qt uses a 16th of a degree for angles. start_position is therefore multiplied by 16.
|
# Important: Qt uses a 16th of a degree for angles. start_position is therefore multiplied by 16.
|
||||||
start_position: float = self.config.start_position * 16 # type: ignore
|
start_position: float = self.config.start_position * 16 # type: ignore
|
||||||
|
|
||||||
adjusted_rect = QtCore.QRect(
|
adjusted_rect = QtCore.QRectF(
|
||||||
rect.left() + gap, rect.top() + gap, rect.width() - 2 * gap, rect.height() - 2 * gap
|
rect.left() + gap, rect.top() + gap, rect.width() - 2 * gap, rect.height() - 2 * gap
|
||||||
)
|
)
|
||||||
|
if self._hover_progress > 0.0:
|
||||||
|
hover_radius_delta = 4.0
|
||||||
|
base_radius = adjusted_rect.width() / 2
|
||||||
|
if base_radius > 0:
|
||||||
|
target_radius = base_radius + (hover_radius_delta * self._hover_progress)
|
||||||
|
scale = target_radius / base_radius
|
||||||
|
center = adjusted_rect.center()
|
||||||
|
new_width = adjusted_rect.width() * scale
|
||||||
|
new_height = adjusted_rect.height() * scale
|
||||||
|
adjusted_rect = QtCore.QRectF(
|
||||||
|
center.x() - new_width / 2, center.y() - new_height / 2, new_width, new_height
|
||||||
|
)
|
||||||
painter.drawArc(adjusted_rect, start_position, 360 * 16)
|
painter.drawArc(adjusted_rect, start_position, 360 * 16)
|
||||||
|
|
||||||
# Foreground arc
|
# Foreground arc
|
||||||
pen = QtGui.QPen(self.color, self.config.line_width, QtCore.Qt.PenStyle.SolidLine)
|
pen = QtGui.QPen(self.color, current_line_width, QtCore.Qt.PenStyle.SolidLine)
|
||||||
pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap)
|
pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap)
|
||||||
painter.setPen(pen)
|
painter.setPen(pen)
|
||||||
proportion = (self.config.value - self.config.min_value) / (
|
proportion = (self.config.value - self.config.min_value) / (
|
||||||
@@ -449,7 +483,17 @@ class Ring(BECConnector, QWidget):
|
|||||||
painter.drawArc(adjusted_rect, start_position, angle)
|
painter.drawArc(adjusted_rect, start_position, angle)
|
||||||
painter.end()
|
painter.end()
|
||||||
|
|
||||||
def convert_color(self, color: str | tuple | QColor) -> QColor:
|
def set_hovered(self, hovered: bool):
|
||||||
|
if hovered == self._hovered:
|
||||||
|
return
|
||||||
|
self._hovered = hovered
|
||||||
|
self._hover_animation.stop()
|
||||||
|
self._hover_animation.setStartValue(self._hover_progress)
|
||||||
|
self._hover_animation.setEndValue(1.0 if hovered else 0.0)
|
||||||
|
self._hover_animation.start()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def convert_color(color: str | tuple | QColor) -> QColor:
|
||||||
"""
|
"""
|
||||||
Convert the color to QColor
|
Convert the color to QColor
|
||||||
|
|
||||||
@@ -485,7 +529,7 @@ class Ring(BECConnector, QWidget):
|
|||||||
@gap.setter
|
@gap.setter
|
||||||
def gap(self, value: int):
|
def gap(self, value: int):
|
||||||
self._gap = value
|
self._gap = value
|
||||||
self.update()
|
self._request_update()
|
||||||
|
|
||||||
@SafeProperty(bool)
|
@SafeProperty(bool)
|
||||||
def link_colors(self) -> bool:
|
def link_colors(self) -> bool:
|
||||||
@@ -522,7 +566,7 @@ class Ring(BECConnector, QWidget):
|
|||||||
float(max(self.config.min_value, min(self.config.max_value, value))),
|
float(max(self.config.min_value, min(self.config.max_value, value))),
|
||||||
self.config.precision,
|
self.config.precision,
|
||||||
)
|
)
|
||||||
self.update()
|
self._request_update()
|
||||||
|
|
||||||
@SafeProperty(float)
|
@SafeProperty(float)
|
||||||
def min_value(self) -> float:
|
def min_value(self) -> float:
|
||||||
@@ -531,7 +575,7 @@ class Ring(BECConnector, QWidget):
|
|||||||
@min_value.setter
|
@min_value.setter
|
||||||
def min_value(self, value: float):
|
def min_value(self, value: float):
|
||||||
self.config.min_value = value
|
self.config.min_value = value
|
||||||
self.update()
|
self._request_update()
|
||||||
|
|
||||||
@SafeProperty(float)
|
@SafeProperty(float)
|
||||||
def max_value(self) -> float:
|
def max_value(self) -> float:
|
||||||
@@ -540,7 +584,7 @@ class Ring(BECConnector, QWidget):
|
|||||||
@max_value.setter
|
@max_value.setter
|
||||||
def max_value(self, value: float):
|
def max_value(self, value: float):
|
||||||
self.config.max_value = value
|
self.config.max_value = value
|
||||||
self.update()
|
self._request_update()
|
||||||
|
|
||||||
@SafeProperty(str)
|
@SafeProperty(str)
|
||||||
def mode(self) -> str:
|
def mode(self) -> str:
|
||||||
@@ -549,6 +593,7 @@ class Ring(BECConnector, QWidget):
|
|||||||
@mode.setter
|
@mode.setter
|
||||||
def mode(self, value: str):
|
def mode(self, value: str):
|
||||||
self.set_update(value)
|
self.set_update(value)
|
||||||
|
self._request_update()
|
||||||
|
|
||||||
@SafeProperty(str)
|
@SafeProperty(str)
|
||||||
def device(self) -> str:
|
def device(self) -> str:
|
||||||
@@ -557,6 +602,7 @@ class Ring(BECConnector, QWidget):
|
|||||||
@device.setter
|
@device.setter
|
||||||
def device(self, value: str):
|
def device(self, value: str):
|
||||||
self.config.device = value
|
self.config.device = value
|
||||||
|
self._request_update()
|
||||||
|
|
||||||
@SafeProperty(str)
|
@SafeProperty(str)
|
||||||
def signal(self) -> str:
|
def signal(self) -> str:
|
||||||
@@ -565,6 +611,7 @@ class Ring(BECConnector, QWidget):
|
|||||||
@signal.setter
|
@signal.setter
|
||||||
def signal(self, value: str):
|
def signal(self, value: str):
|
||||||
self.config.signal = value
|
self.config.signal = value
|
||||||
|
self._request_update()
|
||||||
|
|
||||||
@SafeProperty(int)
|
@SafeProperty(int)
|
||||||
def line_width(self) -> int:
|
def line_width(self) -> int:
|
||||||
@@ -573,7 +620,7 @@ class Ring(BECConnector, QWidget):
|
|||||||
@line_width.setter
|
@line_width.setter
|
||||||
def line_width(self, value: int):
|
def line_width(self, value: int):
|
||||||
self.config.line_width = value
|
self.config.line_width = value
|
||||||
self.update()
|
self._request_update()
|
||||||
|
|
||||||
@SafeProperty(int)
|
@SafeProperty(int)
|
||||||
def start_position(self) -> int:
|
def start_position(self) -> int:
|
||||||
@@ -582,7 +629,7 @@ class Ring(BECConnector, QWidget):
|
|||||||
@start_position.setter
|
@start_position.setter
|
||||||
def start_position(self, value: int):
|
def start_position(self, value: int):
|
||||||
self.config.start_position = value
|
self.config.start_position = value
|
||||||
self.update()
|
self._request_update()
|
||||||
|
|
||||||
@SafeProperty(int)
|
@SafeProperty(int)
|
||||||
def precision(self) -> int:
|
def precision(self) -> int:
|
||||||
@@ -591,7 +638,7 @@ class Ring(BECConnector, QWidget):
|
|||||||
@precision.setter
|
@precision.setter
|
||||||
def precision(self, value: int):
|
def precision(self, value: int):
|
||||||
self.config.precision = value
|
self.config.precision = value
|
||||||
self.update()
|
self._request_update()
|
||||||
|
|
||||||
@SafeProperty(int)
|
@SafeProperty(int)
|
||||||
def direction(self) -> int:
|
def direction(self) -> int:
|
||||||
@@ -600,7 +647,27 @@ class Ring(BECConnector, QWidget):
|
|||||||
@direction.setter
|
@direction.setter
|
||||||
def direction(self, value: int):
|
def direction(self, value: int):
|
||||||
self.config.direction = value
|
self.config.direction = value
|
||||||
self.update()
|
self._request_update()
|
||||||
|
|
||||||
|
@SafeProperty(float)
|
||||||
|
def hover_progress(self) -> float:
|
||||||
|
return self._hover_progress
|
||||||
|
|
||||||
|
@hover_progress.setter
|
||||||
|
def hover_progress(self, value: float):
|
||||||
|
self._hover_progress = value
|
||||||
|
self._request_update(refresh_tooltip=False)
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
"""
|
||||||
|
Cleanup the ring widget.
|
||||||
|
Disconnect any registered slots.
|
||||||
|
"""
|
||||||
|
if self.registered_slot is not None:
|
||||||
|
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
|
||||||
|
self.registered_slot = None
|
||||||
|
self._hover_animation.stop()
|
||||||
|
super().cleanup()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
|||||||
@@ -3,15 +3,16 @@ from typing import Literal
|
|||||||
|
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
from bec_lib.logger import bec_logger
|
from bec_lib.logger import bec_logger
|
||||||
from qtpy.QtCore import QSize, Qt
|
from qtpy.QtCore import QPointF, QSize, Qt
|
||||||
from qtpy.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
|
from qtpy.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils import Colors
|
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
|
from bec_widgets.utils.colors import Colors
|
||||||
from bec_widgets.utils.error_popups import SafeProperty
|
from bec_widgets.utils.error_popups import SafeProperty
|
||||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||||
|
from bec_widgets.widgets.containers.main_window.addons.hover_widget import WidgetTooltip
|
||||||
from bec_widgets.widgets.progress.ring_progress_bar.ring import Ring
|
from bec_widgets.widgets.progress.ring_progress_bar.ring import Ring
|
||||||
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_settings_cards import RingSettings
|
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_settings_cards import RingSettings
|
||||||
|
|
||||||
@@ -29,7 +30,16 @@ class RingProgressContainerWidget(QWidget):
|
|||||||
self.rings: list[Ring] = []
|
self.rings: list[Ring] = []
|
||||||
self.gap = 20 # Gap between rings
|
self.gap = 20 # Gap between rings
|
||||||
self.color_map: str = "turbo"
|
self.color_map: str = "turbo"
|
||||||
|
self._hovered_ring: Ring | None = None
|
||||||
|
self._last_hover_global_pos = None
|
||||||
|
self._hover_tooltip_label = QLabel()
|
||||||
|
self._hover_tooltip_label.setWordWrap(True)
|
||||||
|
self._hover_tooltip_label.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
self._hover_tooltip_label.setMaximumWidth(260)
|
||||||
|
self._hover_tooltip_label.setStyleSheet("font-size: 12px;")
|
||||||
|
self._hover_tooltip = WidgetTooltip(self._hover_tooltip_label)
|
||||||
self.setLayout(QHBoxLayout())
|
self.setLayout(QHBoxLayout())
|
||||||
|
self.setMouseTracking(True)
|
||||||
self.initialize_bars()
|
self.initialize_bars()
|
||||||
self.initialize_center_label()
|
self.initialize_center_label()
|
||||||
|
|
||||||
@@ -59,6 +69,7 @@ class RingProgressContainerWidget(QWidget):
|
|||||||
"""
|
"""
|
||||||
ring = Ring(parent=self)
|
ring = Ring(parent=self)
|
||||||
ring.setGeometry(self.rect())
|
ring.setGeometry(self.rect())
|
||||||
|
ring.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||||||
ring.gap = self.gap * len(self.rings)
|
ring.gap = self.gap * len(self.rings)
|
||||||
ring.set_value(0)
|
ring.set_value(0)
|
||||||
self.rings.append(ring)
|
self.rings.append(ring)
|
||||||
@@ -88,6 +99,10 @@ class RingProgressContainerWidget(QWidget):
|
|||||||
index = self.num_bars - 1
|
index = self.num_bars - 1
|
||||||
index = self._validate_index(index)
|
index = self._validate_index(index)
|
||||||
ring = self.rings[index]
|
ring = self.rings[index]
|
||||||
|
if ring is self._hovered_ring:
|
||||||
|
self._hovered_ring = None
|
||||||
|
self._last_hover_global_pos = None
|
||||||
|
self._hover_tooltip.hide()
|
||||||
ring.cleanup()
|
ring.cleanup()
|
||||||
ring.close()
|
ring.close()
|
||||||
ring.deleteLater()
|
ring.deleteLater()
|
||||||
@@ -106,6 +121,7 @@ class RingProgressContainerWidget(QWidget):
|
|||||||
|
|
||||||
self.center_label = QLabel("", parent=self)
|
self.center_label = QLabel("", parent=self)
|
||||||
self.center_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
self.center_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.center_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||||||
layout.addWidget(self.center_label)
|
layout.addWidget(self.center_label)
|
||||||
|
|
||||||
def _calculate_minimum_size(self):
|
def _calculate_minimum_size(self):
|
||||||
@@ -150,6 +166,130 @@ class RingProgressContainerWidget(QWidget):
|
|||||||
for ring in self.rings:
|
for ring in self.rings:
|
||||||
ring.setGeometry(self.rect())
|
ring.setGeometry(self.rect())
|
||||||
|
|
||||||
|
def enterEvent(self, event):
|
||||||
|
self.setMouseTracking(True)
|
||||||
|
super().enterEvent(event)
|
||||||
|
|
||||||
|
def mouseMoveEvent(self, event):
|
||||||
|
pos = event.position() if hasattr(event, "position") else QPointF(event.pos())
|
||||||
|
self._last_hover_global_pos = (
|
||||||
|
event.globalPosition().toPoint()
|
||||||
|
if hasattr(event, "globalPosition")
|
||||||
|
else event.globalPos()
|
||||||
|
)
|
||||||
|
ring = self._ring_at_pos(pos)
|
||||||
|
self._set_hovered_ring(ring, event)
|
||||||
|
super().mouseMoveEvent(event)
|
||||||
|
|
||||||
|
def leaveEvent(self, event):
|
||||||
|
self._last_hover_global_pos = None
|
||||||
|
self._set_hovered_ring(None, event)
|
||||||
|
super().leaveEvent(event)
|
||||||
|
|
||||||
|
def _set_hovered_ring(self, ring: Ring | None, event=None):
|
||||||
|
if ring is self._hovered_ring:
|
||||||
|
if ring is not None:
|
||||||
|
self.refresh_hover_tooltip(ring, event)
|
||||||
|
return
|
||||||
|
if self._hovered_ring is not None:
|
||||||
|
self._hovered_ring.set_hovered(False)
|
||||||
|
self._hovered_ring = ring
|
||||||
|
if self._hovered_ring is not None:
|
||||||
|
self._hovered_ring.set_hovered(True)
|
||||||
|
self.refresh_hover_tooltip(self._hovered_ring, event)
|
||||||
|
else:
|
||||||
|
self._hover_tooltip.hide()
|
||||||
|
|
||||||
|
def _ring_at_pos(self, pos: QPointF) -> Ring | None:
|
||||||
|
if not self.rings:
|
||||||
|
return None
|
||||||
|
size = min(self.width(), self.height())
|
||||||
|
if size <= 0:
|
||||||
|
return None
|
||||||
|
x_offset = (self.width() - size) / 2
|
||||||
|
y_offset = (self.height() - size) / 2
|
||||||
|
center_x = x_offset + size / 2
|
||||||
|
center_y = y_offset + size / 2
|
||||||
|
dx = pos.x() - center_x
|
||||||
|
dy = pos.y() - center_y
|
||||||
|
distance = (dx * dx + dy * dy) ** 0.5
|
||||||
|
|
||||||
|
max_ring_size = self.get_max_ring_size()
|
||||||
|
base_radius = (size - 2 * max_ring_size) / 2
|
||||||
|
if base_radius <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
best_ring: Ring | None = None
|
||||||
|
best_delta: float | None = None
|
||||||
|
for ring in self.rings:
|
||||||
|
radius = base_radius - ring.gap
|
||||||
|
if radius <= 0:
|
||||||
|
continue
|
||||||
|
half_width = ring.config.line_width / 2
|
||||||
|
inner = radius - half_width
|
||||||
|
outer = radius + half_width
|
||||||
|
if inner <= distance <= outer:
|
||||||
|
delta = abs(distance - radius)
|
||||||
|
if best_delta is None or delta < best_delta:
|
||||||
|
best_delta = delta
|
||||||
|
best_ring = ring
|
||||||
|
|
||||||
|
return best_ring
|
||||||
|
|
||||||
|
def is_ring_hovered(self, ring: Ring) -> bool:
|
||||||
|
return ring is self._hovered_ring
|
||||||
|
|
||||||
|
def refresh_hover_tooltip(self, ring: Ring, event=None):
|
||||||
|
text = self._build_tooltip_text(ring)
|
||||||
|
if event is not None:
|
||||||
|
self._last_hover_global_pos = (
|
||||||
|
event.globalPosition().toPoint()
|
||||||
|
if hasattr(event, "globalPosition")
|
||||||
|
else event.globalPos()
|
||||||
|
)
|
||||||
|
if self._last_hover_global_pos is None:
|
||||||
|
return
|
||||||
|
self._hover_tooltip_label.setText(text)
|
||||||
|
self._hover_tooltip.apply_theme()
|
||||||
|
self._hover_tooltip.show_near(self._last_hover_global_pos)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_tooltip_text(ring: Ring) -> str:
|
||||||
|
mode = ring.config.mode
|
||||||
|
mode_label = {"manual": "Manual", "scan": "Scan progress", "device": "Device"}.get(
|
||||||
|
mode, mode
|
||||||
|
)
|
||||||
|
|
||||||
|
precision = int(ring.config.precision)
|
||||||
|
value = ring.config.value
|
||||||
|
min_value = ring.config.min_value
|
||||||
|
max_value = ring.config.max_value
|
||||||
|
range_span = max(max_value - min_value, 1e-9)
|
||||||
|
progress = max(0.0, min(100.0, ((value - min_value) / range_span) * 100))
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"Mode: {mode_label}",
|
||||||
|
f"Progress: {value:.{precision}f} / {max_value:.{precision}f} ({progress:.1f}%)",
|
||||||
|
]
|
||||||
|
if min_value != 0:
|
||||||
|
lines.append(f"Range: {min_value:.{precision}f} -> {max_value:.{precision}f}")
|
||||||
|
if mode == "device" and ring.config.device:
|
||||||
|
if ring.config.signal:
|
||||||
|
lines.append(f"Device: {ring.config.device}:{ring.config.signal}")
|
||||||
|
else:
|
||||||
|
lines.append(f"Device: {ring.config.device}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def closeEvent(self, event):
|
||||||
|
# Ensure the hover tooltip is properly cleaned up when this widget closes
|
||||||
|
tooltip = getattr(self, "_hover_tooltip", None)
|
||||||
|
if tooltip is not None:
|
||||||
|
tooltip.close()
|
||||||
|
tooltip.deleteLater()
|
||||||
|
self._hover_tooltip = None
|
||||||
|
super().closeEvent(event)
|
||||||
|
|
||||||
def set_colors_from_map(self, colormap, color_format: Literal["RGB", "HEX"] = "RGB"):
|
def set_colors_from_map(self, colormap, color_format: Literal["RGB", "HEX"] = "RGB"):
|
||||||
"""
|
"""
|
||||||
Set the colors for the progress bars from a colormap.
|
Set the colors for the progress bars from a colormap.
|
||||||
@@ -230,6 +370,9 @@ class RingProgressContainerWidget(QWidget):
|
|||||||
"""
|
"""
|
||||||
Clear all rings from the widget.
|
Clear all rings from the widget.
|
||||||
"""
|
"""
|
||||||
|
self._hovered_ring = None
|
||||||
|
self._last_hover_global_pos = None
|
||||||
|
self._hover_tooltip.hide()
|
||||||
for ring in self.rings:
|
for ring in self.rings:
|
||||||
ring.close()
|
ring.close()
|
||||||
ring.deleteLater()
|
ring.deleteLater()
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ from qtpy.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from bec_widgets.utils import UILoader
|
|
||||||
from bec_widgets.utils.error_popups import SafeSlot
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
from bec_widgets.utils.settings_dialog import SettingWidget
|
from bec_widgets.utils.settings_dialog import SettingWidget
|
||||||
|
from bec_widgets.utils.ui_loader import UILoader
|
||||||
from bec_widgets.widgets.progress.ring_progress_bar.ring import Ring
|
from bec_widgets.widgets.progress.ring_progress_bar.ring import Ring
|
||||||
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
|
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
|
||||||
|
|
||||||
@@ -63,7 +63,8 @@ class RingCardWidget(QFrame):
|
|||||||
self.mode_combo.setCurrentText(self._get_display_mode_string(self.ring.config.mode))
|
self.mode_combo.setCurrentText(self._get_display_mode_string(self.ring.config.mode))
|
||||||
self._set_widget_mode_enabled(self.ring.config.mode)
|
self._set_widget_mode_enabled(self.ring.config.mode)
|
||||||
|
|
||||||
def _get_theme_color(self, color_name: str) -> QColor | None:
|
@staticmethod
|
||||||
|
def _get_theme_color(color_name: str) -> QColor | None:
|
||||||
app = QApplication.instance()
|
app = QApplication.instance()
|
||||||
if not app:
|
if not app:
|
||||||
return
|
return
|
||||||
@@ -249,12 +250,13 @@ class RingCardWidget(QFrame):
|
|||||||
def _on_signal_changed(self, signal: str):
|
def _on_signal_changed(self, signal: str):
|
||||||
device = self.ui.device_combo_box.currentText()
|
device = self.ui.device_combo_box.currentText()
|
||||||
signal = self.ui.signal_combo_box.get_signal_name()
|
signal = self.ui.signal_combo_box.get_signal_name()
|
||||||
if not device or device not in self.container.bec_dispatcher.client.device_manager.devices:
|
if not device or device not in self.ring.bec_dispatcher.client.device_manager.devices:
|
||||||
return
|
return
|
||||||
self.ring.set_update("device", device=device, signal=signal)
|
self.ring.set_update("device", device=device, signal=signal)
|
||||||
self.ring.config.signal = signal
|
self.ring.config.signal = signal
|
||||||
|
|
||||||
def _unify_mode_string(self, mode: str) -> str:
|
@staticmethod
|
||||||
|
def _unify_mode_string(mode: str) -> str:
|
||||||
"""Convert mode string to a unified format"""
|
"""Convert mode string to a unified format"""
|
||||||
mode = mode.lower()
|
mode = mode.lower()
|
||||||
if mode == "scan progress":
|
if mode == "scan progress":
|
||||||
@@ -263,7 +265,8 @@ class RingCardWidget(QFrame):
|
|||||||
return "device"
|
return "device"
|
||||||
return mode
|
return mode
|
||||||
|
|
||||||
def _get_display_mode_string(self, mode: str) -> str:
|
@staticmethod
|
||||||
|
def _get_display_mode_string(mode: str) -> str:
|
||||||
"""Convert mode string to display format"""
|
"""Convert mode string to display format"""
|
||||||
match mode:
|
match mode:
|
||||||
case "manual":
|
case "manual":
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ class BECAtlasAdminView(BECWidget, QWidget):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
parent=None,
|
parent=None,
|
||||||
atlas_url: str = "https://bec-atlas-dev.psi.ch/api/v1",
|
atlas_url: str = "https://bec-atlas-prod.psi.ch/api/v1",
|
||||||
client=None,
|
client=None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user