1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-17 22:15:35 +02:00

Compare commits

..

31 Commits

Author SHA1 Message Date
42439097e9 ci: add benchmark workflow 2026-04-17 16:06:02 +02:00
semantic-release
846b6e6968 3.5.0
Automatically generated by python-semantic-release
2026-04-14 15:29:09 +00:00
f562c61e3c fix: connect signals the correct way around 2026-04-14 17:28:19 +02:00
bda5d38965 refactor: code cleanup 2026-04-14 17:28:19 +02:00
9b0ec9dd79 fix(bec_console): persistent bec session 2026-04-14 17:28:19 +02:00
1754e759f0 fix: create new bec shell if deleted 2026-04-14 17:28:19 +02:00
308e84d0e1 tests: update tests 2026-04-14 17:28:19 +02:00
fa2ef83bb9 fix: formatting in plugin template 2026-04-14 17:28:19 +02:00
02cb393bb0 feat: add qtermwidget plugin and replace web term 2026-04-14 17:28:19 +02:00
semantic-release
1d3e0214fd 3.4.4
Automatically generated by python-semantic-release
2026-04-14 07:33:15 +00:00
37747babda fix: check for duplicate subscriptions in GUIClient 2026-04-14 09:32:17 +02:00
32f5d486d3 fix: make gui client registry callback non static 2026-04-14 09:32:17 +02:00
0ff1fdc815 fix: remove staticmethod subscription 2026-04-14 09:32:17 +02:00
c7de320ca5 fix: check duplicate stream sub 2026-04-14 09:32:17 +02:00
semantic-release
5b23dce3d0 3.4.3
Automatically generated by python-semantic-release
2026-04-13 09:20:13 +00:00
5e84d3bec6 fix: Set OPHYD_CONTROL_LAYER to dummy for tests 2026-04-13 11:19:22 +02:00
semantic-release
9a2396ee9c 3.4.2
Automatically generated by python-semantic-release
2026-04-01 12:55:30 +00:00
2dab16b684 test: Add tests for admin access 2026-04-01 14:54:28 +02:00
e6c8cd0b1a fix: allow admin user to pass deployment group check 2026-04-01 14:54:28 +02:00
242f8933b2 fix(bec-atlas-admin-view): Fix atlas_url to bec-atlas-prod.psi.ch 2026-04-01 14:54:28 +02:00
semantic-release
83ac6bcd37 3.4.1
Automatically generated by python-semantic-release
2026-04-01 08:51:56 +00:00
90ecd8ea87 fix(ring): hook update hover to update method 2026-04-01 10:51:11 +02:00
copilot-swe-agent[bot]
6e5f6e7fbb test(ring_progress_bar): add unit tests for hover behavior 2026-04-01 10:51:11 +02:00
2f75aaea16 fix(ring): changed inheritance to BECWidget and added cleanup 2026-04-01 10:51:11 +02:00
677550931b fix(ring): minor general fixes 2026-04-01 10:51:11 +02:00
96b5179658 fix(ring_progress_bar): added hover mouse effect 2026-04-01 10:51:11 +02:00
e25b6604d1 fix(hover_widget): make it fancy + mouse tracking 2026-04-01 10:51:11 +02:00
semantic-release
f74c5a4516 3.4.0
Automatically generated by python-semantic-release
2026-03-26 11:25:40 +00:00
a2923752c2 fix(waveform): alignment panel indicators request autoscale if updated 2026-03-26 12:24:56 +01:00
a486c52058 feat(waveform): 1D alignment mode panel 2026-03-26 12:24:56 +01:00
31389a3dd0 fix(lmfit_dialog): compact layout size policy for better alignment panel UX 2026-03-26 12:24:56 +01:00
61 changed files with 4523 additions and 1499 deletions

View File

@@ -62,4 +62,4 @@ runs:
uv pip install --system -e ./ophyd_devices
uv pip install --system -e ./bec/bec_lib[dev]
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]

166
.github/scripts/aggregate_benchmarks.py vendored Normal file
View File

@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""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())

411
.github/scripts/compare_benchmarks.py vendored Normal file
View File

@@ -0,0 +1,411 @@
#!/usr/bin/env python3
"""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.
"""
name: str
baseline: float
current: float
delta_percent: float
unit: str
metric: str
regressed: 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
else:
regressed = 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,
)
)
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 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]
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}).",
]
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.")
if sorted_comparisons:
lines.extend(
[
"",
"<details>",
"<summary>All benchmark results</summary>",
"",
"| Benchmark | Baseline | Current | Change | Status |",
"| --- | ---: | ---: | ---: | --- |",
]
)
for comparison in sorted_comparisons:
status = "regressed" if comparison.regressed else "ok"
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"{status} |"
)
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())

69
.github/scripts/run_benchmarks.sh vendored Normal file
View File

@@ -0,0 +1,69 @@
#!/usr/bin/env bash
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"

122
.github/scripts/run_with_bec_servers.py vendored Normal file
View File

@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""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())

239
.github/workflows/benchmark.yml vendored Normal file
View File

@@ -0,0 +1,239 @@
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: 10
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: .
- 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

View File

@@ -1,19 +1,19 @@
name: Full CI
on:
on:
push:
pull_request:
workflow_dispatch:
inputs:
BEC_WIDGETS_BRANCH:
description: 'Branch of BEC Widgets to install'
description: "Branch of BEC Widgets to install"
required: false
type: string
BEC_CORE_BRANCH:
description: 'Branch of BEC Core to install'
description: "Branch of BEC Core to install"
required: false
type: string
OPHYD_DEVICES_BRANCH:
description: 'Branch of Ophyd Devices to install'
description: "Branch of Ophyd Devices to install"
required: false
type: string
@@ -23,6 +23,7 @@ concurrency:
permissions:
pull-requests: write
contents: read
jobs:
check_pr_status:
@@ -33,6 +34,15 @@ jobs:
if: needs.check_pr_status.outputs.branch-pr == ''
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:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
@@ -69,9 +79,9 @@ jobs:
uses: ./.github/workflows/child_repos.yml
with:
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main'}}
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main'}}
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
plugin_repos:
needs: [check_pr_status, formatter]
if: needs.check_pr_status.outputs.branch-pr == ''
@@ -81,4 +91,4 @@ jobs:
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
secrets:
GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }}
GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }}

View File

@@ -1,25 +1,25 @@
name: Run Pytest with different Python versions
on:
on:
workflow_call:
inputs:
pr_number:
description: 'Pull request number'
description: "Pull request number"
required: false
type: number
BEC_CORE_BRANCH:
description: 'Branch of BEC Core to install'
description: "Branch of BEC Core to install"
required: false
default: 'main'
default: "main"
type: string
OPHYD_DEVICES_BRANCH:
description: 'Branch of Ophyd Devices to install'
description: "Branch of Ophyd Devices to install"
required: false
default: 'main'
default: "main"
type: string
BEC_WIDGETS_BRANCH:
description: 'Branch of BEC Widgets to install'
description: "Branch of BEC Widgets to install"
required: false
default: 'main'
default: "main"
type: string
jobs:
@@ -30,15 +30,14 @@ jobs:
python-version: ["3.11", "3.12", "3.13"]
env:
BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets
BEC_CORE_BRANCH: main # Set the branch you want for bec
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
PROJECT_PATH: ${{ github.repository }}
QTWEBENGINE_DISABLE_SANDBOX: 1
QT_QPA_PLATFORM: "offscreen"
steps:
- name: Checkout BEC Widgets
uses: actions/checkout@v4
with:
@@ -56,4 +55,4 @@ jobs:
- name: Run Pytest
run: |
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

View File

@@ -1,32 +1,30 @@
name: Run Pytest with Coverage
on:
on:
workflow_call:
inputs:
pr_number:
description: 'Pull request number'
description: "Pull request number"
required: false
type: number
BEC_CORE_BRANCH:
description: 'Branch of BEC Core to install'
description: "Branch of BEC Core to install"
required: false
default: 'main'
default: "main"
type: string
OPHYD_DEVICES_BRANCH:
description: 'Branch of Ophyd Devices to install'
description: "Branch of Ophyd Devices to install"
required: false
default: 'main'
default: "main"
type: string
BEC_WIDGETS_BRANCH:
description: 'Branch of BEC Widgets to install'
description: "Branch of BEC Widgets to install"
required: false
default: 'main'
default: "main"
type: string
secrets:
CODECOV_TOKEN:
required: true
permissions:
pull-requests: write
@@ -55,7 +53,7 @@ jobs:
- name: Run Pytest with 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
uses: actions/upload-artifact@v4
@@ -69,4 +67,4 @@ jobs:
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: bec-project/bec_widgets
slug: bec-project/bec_widgets

4
.gitignore vendored
View File

@@ -177,4 +177,6 @@ cython_debug/
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
#.idea/
#
tombi.toml

View File

@@ -1,6 +1,115 @@
# CHANGELOG
## 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

View File

@@ -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.dock_area import BECDockArea
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_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
@@ -96,7 +96,7 @@ class DeveloperWidget(DockAreaWidget):
self.console = BECShell(self, rpc_exposed=False)
self.console.setObjectName("BEC Shell")
self.terminal = WebConsole(self, rpc_exposed=False)
self.terminal = BecConsole(self, rpc_exposed=False)
self.terminal.setObjectName("Terminal")
self.monaco = MonacoDock(self, rpc_exposed=False, rpc_passthrough_children=False)
self.monaco.setObjectName("MonacoEditor")
@@ -410,23 +410,3 @@ class DeveloperWidget(DockAreaWidget):
"""Clean up resources used by the developer widget."""
self.delete_all()
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_())

View File

@@ -32,6 +32,7 @@ _Widgets = {
"BECQueue": "BECQueue",
"BECShell": "BECShell",
"BECStatusBox": "BECStatusBox",
"BecConsole": "BecConsole",
"DapComboBox": "DapComboBox",
"DeviceBrowser": "DeviceBrowser",
"Heatmap": "Heatmap",
@@ -56,7 +57,6 @@ _Widgets = {
"SignalLabel": "SignalLabel",
"TextBox": "TextBox",
"Waveform": "Waveform",
"WebConsole": "WebConsole",
"WebsiteWidget": "WebsiteWidget",
}
@@ -506,7 +506,7 @@ class BECQueue(RPCBase):
class BECShell(RPCBase):
"""A WebConsole pre-configured to run the BEC shell."""
"""A BecConsole pre-configured to run the BEC shell."""
@rpc_call
def remove(self):
@@ -691,6 +691,28 @@ class BaseROI(RPCBase):
"""
class BecConsole(RPCBase):
"""A console widget with access to a shared registry of terminals, such that instances can be moved around."""
@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):
"""Circular Region of Interest with center/diameter tracking and auto-labeling."""
@@ -6417,28 +6439,6 @@ 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):
"""A simple widget to display a website"""

View File

@@ -10,9 +10,9 @@ import threading
import time
from contextlib import contextmanager
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.utils.import_utils import lazy_import, lazy_import_from
from rich.console import Console
@@ -232,6 +232,11 @@ class BECGuiClient(RPCBase):
"""The launcher object."""
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:
"""Connect to a GUI server"""
# Unregister the old callback
@@ -247,10 +252,9 @@ class BECGuiClient(RPCBase):
self._ipython_registry = {}
# Register the new callback
self._client.connector.register(
self._safe_register_stream(
MessageEndpoints.gui_registry_state(self._gui_id),
cb=self._handle_registry_update,
parent=self,
from_start=True,
)
@@ -531,20 +535,14 @@ class BECGuiClient(RPCBase):
def _start(self, wait: bool = False) -> None:
self._killed = False
self._client.connector.register(
MessageEndpoints.gui_registry_state(self._gui_id),
cb=self._handle_registry_update,
parent=self,
self._safe_register_stream(
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
)
return self._start_server(wait=wait)
@staticmethod
def _handle_registry_update(
msg: dict[str, GUIRegistryStateMessage], parent: BECGuiClient
) -> None:
def _handle_registry_update(self, msg: dict[str, GUIRegistryStateMessage]) -> None:
# This was causing a deadlock during shutdown, not sure why.
# with self._lock:
self = parent
self._server_registry = cast(dict[str, RegistryState], msg["data"].state)
self._update_dynamic_namespace(self._server_registry)

View File

@@ -248,9 +248,7 @@ class RPCBase:
self._rpc_response = None
self._msg_wait_event.clear()
self._client.connector.register(
MessageEndpoints.gui_instruction_response(request_id),
cb=self._on_rpc_response,
parent=self,
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
)
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
@@ -276,11 +274,10 @@ class RPCBase:
self._rpc_response = None
return self._create_widget_from_msg_result(msg_result)
@staticmethod
def _on_rpc_response(msg_obj: MessageObject, parent: RPCBase) -> None:
def _on_rpc_response(self, msg_obj: MessageObject) -> None:
msg = cast(messages.RequestResponseMessage, msg_obj.value)
parent._rpc_response = msg
parent._msg_wait_event.set()
self._rpc_response = msg
self._msg_wait_event.set()
def _create_widget_from_msg_result(self, msg_result):
if msg_result is None:

View File

@@ -175,12 +175,15 @@ class BECDispatcher:
cb_info (dict | None): A dictionary containing information about the callback. Defaults to None.
"""
qt_slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info)
if qt_slot not in self._registered_slots:
self._registered_slots[qt_slot] = qt_slot
qt_slot = self._registered_slots[qt_slot]
self.client.connector.register(topics, cb=qt_slot, **kwargs)
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
qt_slot.topics.update(set(topics_str))
if not self.client.connector.any_stream_is_registered(topics, qt_slot):
if qt_slot not in self._registered_slots:
self._registered_slots[qt_slot] = qt_slot
qt_slot = self._registered_slots[qt_slot]
self.client.connector.register(topics, cb=qt_slot, **kwargs)
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(
self, slot: Callable, topics: EndpointInfo | str | list[EndpointInfo] | list[str]

View File

@@ -22,7 +22,7 @@ class {plugin_name_pascal}Plugin(QDesignerCustomWidgetInterface): # pragma: no
def createWidget(self, parent):
if parent is None:
return QWidget()
return QWidget()
t = {plugin_name_pascal}(parent)
return t

View File

@@ -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.control.device_control.positioner_box import PositionerBox, PositionerBox2D
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.image.image import Image
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
@@ -372,7 +372,7 @@ class BECDockArea(DockAreaWidget):
"Add Circular ProgressBar",
"RingProgressBar",
),
"terminal": (WebConsole.ICON_NAME, "Add Terminal", "WebConsole"),
"terminal": (BecConsole.ICON_NAME, "Add Terminal", "BecConsole"),
"bec_shell": (BECShell.ICON_NAME, "Add BEC Shell", "BECShell"),
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"),
"sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"),

View File

@@ -1,27 +1,83 @@
import sys
from qtpy import QtGui, QtWidgets
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):
"""Frameless, always-on-top window that behaves like a tooltip."""
def __init__(self, content: QWidget) -> None:
super().__init__(None, Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
self.setAttribute(Qt.WA_ShowWithoutActivating)
super().__init__(
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.content = content
layout = QVBoxLayout(self)
layout.setContentsMargins(6, 6, 6, 6)
layout.addWidget(self.content)
layout.setContentsMargins(14, 14, 14, 14)
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()
def leaveEvent(self, _event) -> None:
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:
"""
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()
screen = QApplication.screenAt(global_pos) or QApplication.primaryScreen()
screen_geo = screen.availableGeometry()
@@ -30,11 +86,43 @@ class WidgetTooltip(QWidget):
x = global_pos.x() - geom.width() // 2
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()))
y = max(screen_geo.top(), min(y, screen_geo.bottom() - geom.height()))
self.move(x, y)
self.show()
self.raise_()
class HoverWidget(QWidget):

View File

@@ -3,7 +3,7 @@ import os
import shiboken6
from bec_lib.logger import bec_logger
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
@@ -69,6 +69,27 @@ class LMFitDialog(BECWidget, QWidget):
self._hide_curve_selection = False
self._hide_summary = 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
def enable_actions(self) -> bool:

View File

@@ -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_())

View File

@@ -0,0 +1 @@
{'files': ['bec_console.py']}

View File

@@ -5,17 +5,17 @@ from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
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 = """
<ui language='c++'>
<widget class='WebConsole' name='web_console'>
<widget class='BecConsole' name='bec_console'>
</widget>
</ui>
"""
class WebConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
class BecConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
@@ -23,20 +23,20 @@ class WebConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def createWidget(self, parent):
if parent is None:
return QWidget()
t = WebConsole(parent)
t = BecConsole(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Developer"
return ""
def icon(self):
return designer_material_icon(WebConsole.ICON_NAME)
return designer_material_icon(BecConsole.ICON_NAME)
def includeFile(self):
return "web_console"
return "bec_console"
def initialize(self, form_editor):
self._form_editor = form_editor
@@ -48,10 +48,10 @@ class WebConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return self._form_editor is not None
def name(self):
return "WebConsole"
return "BecConsole"
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):
return self.toolTip()

View File

@@ -0,0 +1 @@
{'files': ['bec_console.py']}

View File

@@ -5,7 +5,7 @@ from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
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 = """
<ui language='c++'>

View File

@@ -6,9 +6,9 @@ def main(): # pragma: no cover
return
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

View File

@@ -6,7 +6,7 @@ def main(): # pragma: no cover
return
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())

View File

@@ -1 +0,0 @@
{'files': ['web_console.py']}

View File

@@ -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_())

View File

@@ -1 +0,0 @@
{'files': ['web_console.py']}

View File

@@ -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))

View File

@@ -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))

View File

@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Literal
import numpy as np
import pyqtgraph as pg
from bec_lib import bec_logger, messages
from bec_lib.device import Positioner
from bec_lib.endpoints import MessageEndpoints
from bec_lib.lmfit_serializer import serialize_lmfit_params, serialize_param_object
from bec_lib.scan_data_container import ScanDataContainer
@@ -30,11 +31,18 @@ from bec_widgets.utils.colors import Colors, apply_theme
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
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.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
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.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.services.scan_history_browser.scan_history_browser import (
ScanHistoryBrowser,
@@ -156,6 +164,12 @@ class Waveform(PlotBase):
"label_suffix": "",
}
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
self._init_roi_manager()
@@ -165,6 +179,7 @@ class Waveform(PlotBase):
self._add_waveform_specific_popup()
self._enable_roi_toolbar_action(False) # default state where are no dap curves
self._init_curve_dialog()
self._init_alignment_mode()
self.curve_settings_dialog = None
# Largedataset guard
@@ -195,7 +210,9 @@ class Waveform(PlotBase):
# To fix the ViewAll action with clipToView activated
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):
"""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.
"""
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)
def add_side_menus(self):
@@ -230,6 +253,159 @@ class Waveform(PlotBase):
super().add_side_menus()
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):
"""
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
of the ViewBox does no longer work as expected. This method deactivates the
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.
"""
@@ -544,6 +720,7 @@ class Waveform(PlotBase):
self.sync_signal_update.emit()
self.plot_item.enableAutoRange(x=True)
self.round_plot_widget.apply_plot_widget_style() # To keep the correct theme
self._refresh_alignment_state(force_readback=True)
@SafeProperty(str)
def signal_x(self) -> str | None:
@@ -573,6 +750,7 @@ class Waveform(PlotBase):
self.sync_signal_update.emit()
self.plot_item.enableAutoRange(x=True)
self.round_plot_widget.apply_plot_widget_style()
self._refresh_alignment_state(force_readback=True)
@SafeProperty(str)
def color_palette(self) -> str:
@@ -627,6 +805,8 @@ class Waveform(PlotBase):
continue
config = CurveConfig(**cfg_dict)
self._add_curve(config=config)
self._refresh_alignment_state(force_readback=self._alignment_panel_visible)
self._refresh_dap_signals()
except json.JSONDecodeError as e:
logger.error(f"Failed to decode JSON: {e}")
@@ -1002,6 +1182,7 @@ class Waveform(PlotBase):
QTimer.singleShot(
150, self.auto_range
) # autorange with a delay to ensure the plot is updated
self._refresh_alignment_state()
return curve
@@ -1257,6 +1438,7 @@ class Waveform(PlotBase):
self.remove_curve(curve.name())
if self.crosshair is not None:
self.crosshair.clear_markers()
self._refresh_alignment_state()
def get_curve(self, curve: int | str) -> Curve | None:
"""
@@ -1292,6 +1474,7 @@ class Waveform(PlotBase):
self._refresh_colors()
self._categorise_device_curves()
self._refresh_alignment_state()
def _remove_curve_by_name(self, name: str):
"""
@@ -1342,6 +1525,8 @@ class Waveform(PlotBase):
and self.enable_side_panel is True
):
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
for c in self.curves:
@@ -1983,6 +2168,7 @@ class Waveform(PlotBase):
"""
x_data = None
new_suffix = None
previous_x_device = self._current_x_device
data, access_key = self._fetch_scan_data_and_access()
# 1 User wants custom signal
@@ -2041,6 +2227,7 @@ class Waveform(PlotBase):
if not scan_report_devices:
x_data = None
new_suffix = " (auto: index)"
self._current_x_device = None
else:
device_x = scan_report_devices[0]
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)
x_data = entry_obj.read()["value"] if entry_obj else None
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)
if previous_x_device != self._current_x_device:
self._refresh_alignment_state(force_readback=True)
return x_data
def _update_x_label_suffix(self, new_suffix: str):
@@ -2096,7 +2285,7 @@ class Waveform(PlotBase):
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:
self.update_with_scan_history(-1)
@@ -2453,6 +2642,8 @@ class Waveform(PlotBase):
Cleanup the widget by disconnecting signals and closing dialogs.
"""
self.proxy_dap_request.cleanup()
if self._alignment_controller is not None:
self._alignment_controller.cleanup()
self.clear_all()
if self.curve_settings_dialog is not None:
self.curve_settings_dialog.reject()

View File

@@ -9,7 +9,8 @@ from qtpy import QtCore, QtGui
from qtpy.QtGui import QColor
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.error_popups import SafeProperty, SafeSlot
@@ -40,7 +41,7 @@ class ProgressbarConfig(ConnectionConfig):
line_width: int = Field(20, description="Line widths for the progress bars.")
start_position: int = Field(
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.",
)
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 = [
"set_value",
"set_color",
@@ -82,8 +83,26 @@ class Ring(BECConnector, QWidget):
self.registered_slot: tuple[Callable, str | EndpointInfo] | None = None
self.RID = None
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)
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):
"""
Set the value for the ring widget
@@ -107,7 +126,7 @@ class Ring(BECConnector, QWidget):
if self.config.link_colors:
self._auto_set_background_color()
self.update()
self._request_update()
def set_background(self, color: str | tuple | QColor):
"""
@@ -122,7 +141,7 @@ class Ring(BECConnector, QWidget):
self._background_color = self.convert_color(color)
self.config.background_color = self._background_color.name()
self.update()
self._request_update()
def _auto_set_background_color(self):
"""
@@ -133,7 +152,7 @@ class Ring(BECConnector, QWidget):
bg_color = Colors.subtle_background_color(self._color, bg)
self.config.background_color = bg_color.name()
self._background_color = bg_color
self.update()
self._request_update()
def set_colors_linked(self, linked: bool):
"""
@@ -146,7 +165,7 @@ class Ring(BECConnector, QWidget):
self.config.link_colors = linked
if linked:
self._auto_set_background_color()
self.update()
self._request_update()
def set_line_width(self, width: int):
"""
@@ -156,7 +175,7 @@ class Ring(BECConnector, QWidget):
width(int): Line width for the ring widget
"""
self.config.line_width = width
self.update()
self._request_update()
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.max_value = max_value
self.update()
self._request_update()
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
"""
self.config.start_position = start_angle
self.update()
self._request_update()
def set_update(
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
"""
self.config.precision = precision
self.update()
self._request_update()
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.
"""
self.config.direction = direction
self.update()
self._request_update()
def _get_signals_for_device(self, device: str) -> dict[str, list[str]]:
"""
@@ -424,8 +443,11 @@ class Ring(BECConnector, QWidget):
rect.adjust(max_ring_size, max_ring_size, -max_ring_size, -max_ring_size)
# 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(
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
@@ -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.
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
)
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)
# 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)
painter.setPen(pen)
proportion = (self.config.value - self.config.min_value) / (
@@ -449,7 +483,17 @@ class Ring(BECConnector, QWidget):
painter.drawArc(adjusted_rect, start_position, angle)
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
@@ -485,7 +529,7 @@ class Ring(BECConnector, QWidget):
@gap.setter
def gap(self, value: int):
self._gap = value
self.update()
self._request_update()
@SafeProperty(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))),
self.config.precision,
)
self.update()
self._request_update()
@SafeProperty(float)
def min_value(self) -> float:
@@ -531,7 +575,7 @@ class Ring(BECConnector, QWidget):
@min_value.setter
def min_value(self, value: float):
self.config.min_value = value
self.update()
self._request_update()
@SafeProperty(float)
def max_value(self) -> float:
@@ -540,7 +584,7 @@ class Ring(BECConnector, QWidget):
@max_value.setter
def max_value(self, value: float):
self.config.max_value = value
self.update()
self._request_update()
@SafeProperty(str)
def mode(self) -> str:
@@ -549,6 +593,7 @@ class Ring(BECConnector, QWidget):
@mode.setter
def mode(self, value: str):
self.set_update(value)
self._request_update()
@SafeProperty(str)
def device(self) -> str:
@@ -557,6 +602,7 @@ class Ring(BECConnector, QWidget):
@device.setter
def device(self, value: str):
self.config.device = value
self._request_update()
@SafeProperty(str)
def signal(self) -> str:
@@ -565,6 +611,7 @@ class Ring(BECConnector, QWidget):
@signal.setter
def signal(self, value: str):
self.config.signal = value
self._request_update()
@SafeProperty(int)
def line_width(self) -> int:
@@ -573,7 +620,7 @@ class Ring(BECConnector, QWidget):
@line_width.setter
def line_width(self, value: int):
self.config.line_width = value
self.update()
self._request_update()
@SafeProperty(int)
def start_position(self) -> int:
@@ -582,7 +629,7 @@ class Ring(BECConnector, QWidget):
@start_position.setter
def start_position(self, value: int):
self.config.start_position = value
self.update()
self._request_update()
@SafeProperty(int)
def precision(self) -> int:
@@ -591,7 +638,7 @@ class Ring(BECConnector, QWidget):
@precision.setter
def precision(self, value: int):
self.config.precision = value
self.update()
self._request_update()
@SafeProperty(int)
def direction(self) -> int:
@@ -600,7 +647,27 @@ class Ring(BECConnector, QWidget):
@direction.setter
def direction(self, value: int):
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

View File

@@ -3,7 +3,7 @@ from typing import Literal
import pyqtgraph as pg
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 bec_widgets.utils import Colors
@@ -12,6 +12,7 @@ from bec_widgets.utils.error_popups import SafeProperty
from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.toolbars.actions import MaterialIconAction
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_progress_settings_cards import RingSettings
@@ -29,7 +30,16 @@ class RingProgressContainerWidget(QWidget):
self.rings: list[Ring] = []
self.gap = 20 # Gap between rings
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.setMouseTracking(True)
self.initialize_bars()
self.initialize_center_label()
@@ -59,6 +69,7 @@ class RingProgressContainerWidget(QWidget):
"""
ring = Ring(parent=self)
ring.setGeometry(self.rect())
ring.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
ring.gap = self.gap * len(self.rings)
ring.set_value(0)
self.rings.append(ring)
@@ -88,6 +99,10 @@ class RingProgressContainerWidget(QWidget):
index = self.num_bars - 1
index = self._validate_index(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.close()
ring.deleteLater()
@@ -106,6 +121,7 @@ class RingProgressContainerWidget(QWidget):
self.center_label = QLabel("", parent=self)
self.center_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.center_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
layout.addWidget(self.center_label)
def _calculate_minimum_size(self):
@@ -150,6 +166,130 @@ class RingProgressContainerWidget(QWidget):
for ring in self.rings:
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"):
"""
Set the colors for the progress bars from a colormap.
@@ -230,6 +370,9 @@ class RingProgressContainerWidget(QWidget):
"""
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:
ring.close()
ring.deleteLater()

View File

@@ -63,7 +63,8 @@ class RingCardWidget(QFrame):
self.mode_combo.setCurrentText(self._get_display_mode_string(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()
if not app:
return
@@ -249,12 +250,13 @@ class RingCardWidget(QFrame):
def _on_signal_changed(self, signal: str):
device = self.ui.device_combo_box.currentText()
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
self.ring.set_update("device", device=device, 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"""
mode = mode.lower()
if mode == "scan progress":
@@ -263,7 +265,8 @@ class RingCardWidget(QFrame):
return "device"
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"""
match mode:
case "manual":

View File

@@ -251,7 +251,7 @@ class BECAtlasAdminView(BECWidget, QWidget):
def __init__(
self,
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,
**kwargs,
):

View File

@@ -142,6 +142,17 @@ class BECAtlasHTTPService(QWidget):
if self._auth_user_info is not None:
self._auth_user_info.groups = set(groups)
def __check_access_to_owner_groups(self, groups: list[str]) -> bool:
"""Check if the authenticated user has access to the current deployment based on their groups."""
if self._auth_user_info is None or self._current_deployment_info is None:
return False
# Admin user
has_both = {"admin", "atlas_func_account"}.issubset(groups)
if has_both:
return True
# Regular user check with group intersection
return not self.auth_user_info.groups.isdisjoint(groups)
def __clear_login_info(self, skip_logout: bool = False):
"""Clear the authenticated user information after logout."""
self._auth_user_info = None
@@ -231,9 +242,7 @@ class BECAtlasHTTPService(QWidget):
)
elif AtlasEndpoints.DEPLOYMENT_INFO.value in request_url:
owner_groups = data.get("owner_groups", [])
if self.auth_user_info is not None and not self.auth_user_info.groups.isdisjoint(
owner_groups
):
if self.__check_access_to_owner_groups(owner_groups):
self.authenticated.emit(self.auth_user_info.model_dump())
else:
if self.auth_user_info is not None:

View File

@@ -0,0 +1,11 @@
if __name__ == "__main__": # pragma: no cover
import sys
from pyside6_qtermwidget import QTermWidget # pylint: disable=ungrouped-imports
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
app = QApplication(sys.argv)
widget = QTermWidget()
widget.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,8 @@
from typing import Protocol, runtime_checkable
@runtime_checkable
class BecTerminal(Protocol):
"""Implementors of this protocol must also be subclasses of QWidget"""
def write(self, text: str, add_newline: bool = True): ...

View File

@@ -0,0 +1,241 @@
"""A wrapper for the optional external dependency pyside6_qtermwidget.
Simply displays a message in a QLabel if the dependency is not installed."""
import os
from functools import wraps
from typing import Sequence
from qtpy.QtCore import QIODevice, QPoint, QSize, QUrl, Signal # type: ignore
from qtpy.QtGui import QAction, QFont, QKeyEvent, QResizeEvent, Qt # type: ignore
from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget
try:
from pyside6_qtermwidget import QTermWidget
except ImportError:
QTermWidget = None
def _forward(func):
"""Apply to a private method to forward the call to the method on QTermWidget with the same name,
(with leading '_' removed) if it is defined, otherwise do nothing."""
@wraps(func)
def wrapper(self, *args, **kwargs):
target = getattr(self, "_main_widget")
if QTermWidget:
method = getattr(target, func.__name__[1:])
return method(*args, **kwargs)
else:
...
return wrapper
class BecQTerm(QWidget):
activity = Signal()
bell = Signal(str)
copy_available = Signal(bool)
current_directory_changed = Signal(str)
finished = Signal()
profile_changed = Signal(str)
received_data = Signal(str)
silence = Signal()
term_got_focus = Signal()
term_key_pressed = Signal(QKeyEvent)
term_lost_focus = Signal()
title_changed = Signal()
url_activated = Signal(QUrl, bool)
def __init__(self, /, parent: QWidget | None = None, **kwargs) -> None:
super().__init__(parent)
self._layout = QVBoxLayout()
self.setLayout(self._layout)
if QTermWidget:
self._main_widget = QTermWidget(parent=self)
self._main_widget.activity.connect(self.activity)
self._main_widget.bell.connect(self.bell)
self._main_widget.copyAvailable.connect(self.copy_available)
self._main_widget.currentDirectoryChanged.connect(self.current_directory_changed)
self._main_widget.finished.connect(self.finished)
self._main_widget.profileChanged.connect(self.profile_changed)
self._main_widget.receivedData.connect(self.received_data)
self._main_widget.silence.connect(self.silence)
self._main_widget.termGetFocus.connect(self.term_got_focus)
self._main_widget.termKeyPressed.connect(self.term_key_pressed)
self._main_widget.termLostFocus.connect(self.term_lost_focus)
self._main_widget.titleChanged.connect(self.title_changed)
self._main_widget.urlActivated.connect(self.url_activated)
self._setEnvironment([f"{k}={v}" for k, v in os.environ.items()])
self._setColorScheme("Solarized")
else:
self._layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._main_widget = QLabel("pyside6_qterminal is not installed!")
self._layout.addWidget(self._main_widget)
def write(self, text: str, add_newline: bool = True):
if add_newline:
text += "\n"
self._sendText(text)
# automatically forwarded to the widget only if it exists
@_forward
def _addCustomColorSchemeDir(self, custom_dir: str, /) -> None: ...
@_forward
def _autoHideMouseAfter(self, delay: int, /) -> None: ...
@_forward
def _availableColorSchemes(self) -> list[str]: ...
@_forward
def _availableKeyBindings(self) -> list[str]: ...
@_forward
def _bracketText(self, text: str, /) -> None: ...
@_forward
def _bracketedPasteModeIsDisabled(self, /) -> bool: ...
@_forward
def _changeDir(self, dir: str, /) -> None: ...
@_forward
def _clear(self, /) -> None: ...
@_forward
def _clearCustomKeyBindingsDir(self, /) -> None: ...
@_forward
def _copyClipboard(self, /) -> None: ...
@_forward
def _disableBracketedPasteMode(self, disable: bool, /) -> None: ...
@_forward
def _filterActions(self, position: QPoint, /) -> list[QAction]: ...
@_forward
def _flowControlEnabled(self, /) -> bool: ...
@_forward
def _getAvailableColorSchemes(self, /) -> list[str]: ...
@_forward
def _getForegroundProcessId(self, /) -> int: ...
@_forward
def _getMargin(self, /) -> int: ...
@_forward
def _getPtySlaveFd(self, /) -> int: ...
@_forward
def _getSelectionEnd(self, row: int, column: int, /) -> None: ...
@_forward
def _getSelectionStart(self, row: int, column: int, /) -> None: ...
@_forward
def _getShellPID(self, /) -> int: ...
@_forward
def _getTerminalFont(self, /) -> QFont: ...
@_forward
def _historyLinesCount(self, /) -> int: ...
@_forward
def _historySize(self, /) -> int: ...
@_forward
def _icon(self, /) -> str: ...
@_forward
def _isBidiEnabled(self, /) -> bool: ...
@_forward
def _isTitleChanged(self, /) -> bool: ...
@_forward
def _keyBindings(self, /) -> str: ...
@_forward
def _pasteClipboard(self, /) -> None: ...
@_forward
def _pasteSelection(self, /) -> None: ...
@_forward
def _resizeEvent(self, arg__1: QResizeEvent, /) -> None: ...
@_forward
def _saveHistory(self, device: QIODevice, /) -> None: ...
@_forward
def _screenColumnsCount(self, /) -> int: ...
@_forward
def _screenLinesCount(self, /) -> int: ...
@_forward
def _scrollToEnd(self, /) -> None: ...
@_forward
def _selectedText(self, /, preserveLineBreaks: bool = ...) -> str: ...
@_forward
def _selectionChanged(self, textSelected: bool, /) -> None: ...
@_forward
def _sendKeyEvent(self, e: QKeyEvent, /) -> None: ...
@_forward
def _sendText(self, text: str, /) -> None: ...
@_forward
def _sessionFinished(self, /) -> None: ...
@_forward
def _setArgs(self, args: Sequence[str], /) -> None: ...
@_forward
def _setAutoClose(self, arg__1: bool, /) -> None: ...
@_forward
def _setBidiEnabled(self, enabled: bool, /) -> None: ...
@_forward
def _setBlinkingCursor(self, blink: bool, /) -> None: ...
@_forward
def _setBoldIntense(self, boldIntense: bool, /) -> None: ...
@_forward
def _setColorScheme(self, name: str, /) -> None: ...
@_forward
def _setConfirmMultilinePaste(self, confirmMultilinePaste: bool, /) -> None: ...
@_forward
def _setCustomKeyBindingsDir(self, custom_dir: str, /) -> None: ...
@_forward
def _setDrawLineChars(self, drawLineChars: bool, /) -> None: ...
@_forward
def _setEnvironment(self, environment: Sequence[str], /) -> None: ...
@_forward
def _setFlowControlEnabled(self, enabled: bool, /) -> None: ...
@_forward
def _setFlowControlWarningEnabled(self, enabled: bool, /) -> None: ...
@_forward
def _setHistorySize(self, lines: int, /) -> None: ...
@_forward
def _setKeyBindings(self, kb: str, /) -> None: ...
@_forward
def _setMargin(self, arg__1: int, /) -> None: ...
@_forward
def _setMonitorActivity(self, arg__1: bool, /) -> None: ...
@_forward
def _setMonitorSilence(self, arg__1: bool, /) -> None: ...
@_forward
def _setMotionAfterPasting(self, arg__1: int, /) -> None: ...
@_forward
def _setSelectionEnd(self, row: int, column: int, /) -> None: ...
@_forward
def _setSelectionStart(self, row: int, column: int, /) -> None: ...
@_forward
def _setShellProgram(self, program: str, /) -> None: ...
@_forward
def _setSilenceTimeout(self, seconds: int, /) -> None: ...
@_forward
def _setSize(self, arg__1: QSize, /) -> None: ...
@_forward
def _setTerminalBackgroundImage(self, backgroundImage: str, /) -> None: ...
@_forward
def _setTerminalBackgroundMode(self, mode: int, /) -> None: ...
@_forward
def _setTerminalFont(self, font: QFont | str | Sequence[str], /) -> None: ...
@_forward
def _setTerminalOpacity(self, level: float, /) -> None: ...
@_forward
def _setTerminalSizeHint(self, enabled: bool, /) -> None: ...
@_forward
def _setTrimPastedTrailingNewlines(self, trimPastedTrailingNewlines: bool, /) -> None: ...
@_forward
def _setWordCharacters(self, chars: str, /) -> None: ...
@_forward
def _setWorkingDirectory(self, dir: str, /) -> None: ...
@_forward
def _sizeHint(self, /) -> QSize: ...
@_forward
def _startShellProgram(self, /) -> None: ...
@_forward
def _startTerminalTeletype(self, /) -> None: ...
@_forward
def _terminalSizeHint(self, /) -> bool: ...
@_forward
def _title(self, /) -> str: ...
@_forward
def _toggleShowSearchBar(self, /) -> None: ...
@_forward
def _wordCharacters(self, /) -> str: ...
@_forward
def _workingDirectory(self, /) -> str: ...
@_forward
def _zoomIn(self, /) -> None: ...
@_forward
def _zoomOut(self, /) -> None: ...

View File

@@ -0,0 +1,6 @@
from bec_widgets.widgets.utility.bec_term.protocol import BecTerminal
from bec_widgets.widgets.utility.bec_term.qtermwidget_wrapper import BecQTerm
def get_current_bec_term_class() -> type[BecTerminal]:
return BecQTerm

View File

@@ -1,54 +1,34 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "3.3.4"
version = "3.5.0"
description = "BEC Widgets"
requires-python = ">=3.11"
classifiers = [
"Development Status :: 3 - Alpha",
"Programming Language :: Python :: 3",
"Topic :: Scientific/Engineering",
"Development Status :: 3 - Alpha",
"Programming Language :: Python :: 3",
"Topic :: Scientific/Engineering",
]
dependencies = [
"bec_ipython_client~=3.107,>=3.107.2", # needed for jupyter console
"bec_lib~=3.107,>=3.107.2",
"bec_qthemes~=1.0, >=1.3.4",
"black>=26,<27", # needed for bw-generate-cli
"isort>=5.13, <9.0", # needed for bw-generate-cli
"ophyd_devices~=1.29, >=1.29.1",
"pydantic~=2.0",
"pyqtgraph==0.13.7",
"PySide6==6.9.0",
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"qtpy~=2.4",
"thefuzz~=0.22",
"qtmonaco~=0.8, >=0.8.1",
"darkdetect~=0.8",
"PySide6-QtAds==4.4.0",
"pylsp-bec~=1.2",
"copier~=9.7",
"typer~=0.15",
"markdown~=3.9",
"PyJWT~=2.9",
]
[project.optional-dependencies]
dev = [
"coverage~=7.0",
"fakeredis~=2.23, >=2.23.2",
"pytest-bec-e2e>=2.21.4, <=4.0",
"pytest-qt~=4.4",
"pytest-random-order~=1.1",
"pytest-timeout~=2.2",
"pytest-xvfb~=3.0",
"pytest~=8.0",
"pytest-cov~=6.1.1",
"watchdog~=6.0",
"pre_commit~=4.2",
"PyJWT~=2.9",
"PySide6==6.9.0",
"PySide6-QtAds==4.4.0",
"bec_ipython_client~=3.107,>=3.107.2", # needed for jupyter console
"bec_lib~=3.107,>=3.107.2",
"bec_qthemes~=1.0, >=1.3.4",
"black>=26,<27", # needed for bw-generate-cli
"copier~=9.7",
"darkdetect~=0.8",
"isort>=5.13, <9.0", # needed for bw-generate-cli
"markdown~=3.9",
"ophyd_devices~=1.29, >=1.29.1",
"pydantic~=2.0",
"pylsp-bec~=1.2",
"pyqtgraph==0.13.7",
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"qtmonaco~=0.8, >=0.8.1",
"qtpy~=2.4",
"thefuzz~=0.22",
"typer~=0.15",
]
[project.urls]
@@ -56,10 +36,47 @@ dev = [
Homepage = "https://gitlab.psi.ch/bec/bec_widgets"
[project.scripts]
bw-generate-cli = "bec_widgets.cli.generate_cli:main"
bec-gui-server = "bec_widgets.cli.server:main"
bec-designer = "bec_widgets.utils.bec_designer:main"
bec-app = "bec_widgets.applications.main_app:main"
bec-designer = "bec_widgets.utils.bec_designer:main"
bec-gui-server = "bec_widgets.cli.server:main"
bw-generate-cli = "bec_widgets.cli.generate_cli:main"
[project.optional-dependencies]
dev = [
"coverage~=7.0",
"fakeredis~=2.23, >=2.23.2",
"pytest-bec-e2e>=2.21.4, <=4.0",
"pytest-qt~=4.4",
"pytest-random-order~=1.1",
"pytest-timeout~=2.2",
"pytest-xvfb~=3.0",
"pytest~=8.0",
"pytest-cov~=6.1.1",
"watchdog~=6.0",
"pre_commit~=4.2",
]
qtermwidget = [
"pyside6_qtermwidget",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.black]
line-length = 100
skip-magic-trailing-comma = true
[tool.coverage.report]
skip_empty = true # exclude empty *files*, e.g. __init__.py, from the report
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"return NotImplemented",
"raise NotImplementedError",
"\\.\\.\\.",
'if __name__ == "__main__":',
]
[tool.hatch.build.targets.wheel]
include = ["*"]
@@ -69,10 +86,6 @@ exclude = ["docs/**", "tests/**"]
include = ["*"]
exclude = ["docs/**", "tests/**"]
[tool.black]
line-length = 100
skip-magic-trailing-comma = true
[tool.isort]
profile = "black"
line_length = 100
@@ -80,6 +93,12 @@ multi_line_output = 3
include_trailing_comma = true
known_first_party = ["bec_widgets"]
[tool.ruff]
line-length = 100
[tool.ruff.format]
skip-magic-trailing-comma = true
[tool.semantic_release]
build_command = "pip install build wheel && python -m build"
version_toml = ["pyproject.toml:project.version"]
@@ -90,16 +109,16 @@ default = "semantic-release <semantic-release>"
[tool.semantic_release.commit_parser_options]
allowed_tags = [
"build",
"chore",
"ci",
"docs",
"feat",
"fix",
"perf",
"style",
"refactor",
"test",
"build",
"chore",
"ci",
"docs",
"feat",
"fix",
"perf",
"style",
"refactor",
"test",
]
minor_tags = ["feat"]
patch_tags = ["fix", "perf"]
@@ -116,14 +135,3 @@ env = "GH_TOKEN"
[tool.semantic_release.publish]
dist_glob_patterns = ["dist/*"]
upload_to_vcs_release = true
[tool.coverage.report]
skip_empty = true # exclude empty *files*, e.g. __init__.py, from the report
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"return NotImplemented",
"raise NotImplementedError",
"\\.\\.\\.",
'if __name__ == "__main__":',
]

View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# BENCHMARK_TITLE: Import bec_widgets
set -euo pipefail
python -c 'import bec_widgets; print(bec_widgets.__file__)'

View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# BENCHMARK_TITLE: BEC IPython client with companion app
set -euo pipefail
bec --post-startup-file tests/benchmarks/hyperfine/utils/exit_bec_startup.py

View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# BENCHMARK_TITLE: BEC IPython client without companion app
set -euo pipefail
bec --nogui --post-startup-file tests/benchmarks/hyperfine/utils/exit_bec_startup.py

View File

@@ -0,0 +1,5 @@
import time
_ip = get_ipython()
_ip.confirm_exit = False
_ip.ask_exit()

View File

@@ -1,6 +1,7 @@
import pytest
from bec_widgets.cli.client import Image, MotorMap, Waveform
from bec_widgets.cli.client_utils import BECGuiClient
from bec_widgets.cli.rpc.rpc_base import RPCReference
# pylint: disable=unused-argument
@@ -122,7 +123,7 @@ def test_ring_bar(qtbot, connected_client_gui_obj):
assert gui._ipython_registry[bar._gui_id].__class__.__name__ == "RingProgressBar"
def test_rpc_gui_obj(connected_client_gui_obj, qtbot):
def test_rpc_gui_obj(connected_client_gui_obj: BECGuiClient, qtbot):
gui = connected_client_gui_obj
qtbot.waitUntil(lambda: len(gui.windows) == 1, timeout=3000)

View File

@@ -93,8 +93,8 @@ def test_available_widgets(qtbot, connected_client_gui_obj):
if object_name == "BECShell":
continue
# Skip WebConsole as ttyd is not installed
if object_name == "WebConsole":
# Skip BecConsole as ttyd is not installed
if object_name == "BecConsole":
continue
#############################

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
import pytest
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from tests.unit_tests.client_mocks import mocked_client
@pytest.fixture
def dock_area(qtbot, mocked_client):
widget = BECDockArea(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_add_waveform_to_dock_area(benchmark, dock_area, qtbot, mocked_client):
"""Benchmark adding a Waveform widget to an existing dock area."""
def add_waveform():
dock_area.new("Waveform")
return dock_area
dock = benchmark(add_waveform)
assert dock is not None

View File

@@ -1,3 +1,8 @@
# Force ophyd onto its dummy control layer in tests so importing it does not
# try to create a real EPICS CA context.
import os
os.environ.setdefault("OPHYD_CONTROL_LAYER", "dummy")
import json
import time
from unittest import mock
@@ -13,7 +18,7 @@ from bec_lib.client import BECClient
from bec_lib.messages import _StoredDataInfo
from bec_qthemes import apply_theme
from bec_qthemes._theme import Theme
from ophyd._pyepics_shim import _dispatcher
from ophyd._dummy_shim import _dispatcher
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
from qtpy.QtCore import QEvent, QEventLoop
from qtpy.QtWidgets import QApplication, QMessageBox

View File

@@ -0,0 +1,156 @@
from unittest.mock import MagicMock
import numpy as np
import pyqtgraph as pg
from bec_widgets.widgets.plots.waveform.utils.alignment_controller import (
AlignmentContext,
WaveformAlignmentController,
)
from bec_widgets.widgets.plots.waveform.utils.alignment_panel import WaveformAlignmentPanel
from .client_mocks import mocked_client
from .conftest import create_widget
from .test_waveform import make_alignment_fit_summary
def create_alignment_controller(qtbot, mocked_client):
plot_widget = pg.PlotWidget()
qtbot.addWidget(plot_widget)
panel = create_widget(qtbot, WaveformAlignmentPanel, client=mocked_client)
controller = WaveformAlignmentController(plot_widget.plotItem, panel, parent=plot_widget)
return plot_widget, panel, controller
def test_alignment_controller_shows_marker_only_when_visible(qtbot, mocked_client):
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
controller.update_context(
AlignmentContext(visible=False, positioner_name="samx", precision=3, readback=1.0)
)
controller.update_position(4.2)
assert controller.marker_line is None
controller.update_context(
AlignmentContext(visible=True, positioner_name="samx", precision=3, readback=4.2)
)
assert controller.marker_line is not None
assert np.isclose(controller.marker_line.value(), 4.2)
assert panel.target_toggle.isEnabled() is True
controller.update_context(AlignmentContext(visible=False, positioner_name="samx"))
assert controller.marker_line is None
def test_alignment_controller_target_line_uses_readback_and_limits(qtbot, mocked_client):
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
controller.update_context(
AlignmentContext(
visible=True, positioner_name="samx", precision=3, limits=(0.0, 2.0), readback=5.0
)
)
panel.target_toggle.setChecked(True)
assert controller.target_line is not None
assert np.isclose(controller.target_line.value(), 2.0)
panel.target_toggle.setChecked(False)
assert controller.target_line is None
controller.update_context(
AlignmentContext(
visible=True, positioner_name="samx", precision=3, limits=None, readback=5.0
)
)
panel.target_toggle.setChecked(True)
assert controller.target_line is not None
assert np.isclose(controller.target_line.value(), 5.0)
def test_alignment_controller_preserves_dragged_target_on_context_refresh(qtbot, mocked_client):
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
controller.update_context(
AlignmentContext(
visible=True, positioner_name="samx", precision=3, limits=(0.0, 5.0), readback=1.0
)
)
panel.target_toggle.setChecked(True)
controller.target_line.setValue(3.0)
controller.update_context(
AlignmentContext(
visible=True, positioner_name="samx", precision=3, limits=(0.0, 5.0), readback=2.0
)
)
assert controller.target_line is not None
assert np.isclose(controller.target_line.value(), 3.0)
def test_alignment_controller_emits_move_request_for_fit_center(qtbot, mocked_client):
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
move_callback = MagicMock()
controller.move_absolute_requested.connect(move_callback)
controller.update_context(
AlignmentContext(visible=True, positioner_name="samx", precision=3, readback=1.0)
)
controller.update_dap_summary(make_alignment_fit_summary(center=2.5), {"curve_id": "fit"})
assert panel.fit_dialog.action_buttons["center"].isEnabled() is True
panel.fit_dialog.action_buttons["center"].click()
move_callback.assert_called_once_with(2.5)
def test_alignment_controller_requests_autoscale_for_marker_and_target(qtbot, mocked_client):
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
autoscale_callback = MagicMock()
controller.autoscale_requested.connect(autoscale_callback)
controller.update_context(
AlignmentContext(visible=True, positioner_name="samx", precision=3, readback=1.0)
)
panel.target_toggle.setChecked(True)
assert autoscale_callback.call_count >= 2
def test_alignment_controller_emits_move_request_for_target(qtbot, mocked_client):
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
move_callback = MagicMock()
controller.move_absolute_requested.connect(move_callback)
controller.update_context(
AlignmentContext(
visible=True, positioner_name="samx", precision=3, limits=(0.0, 5.0), readback=1.0
)
)
panel.target_toggle.setChecked(True)
controller.target_line.setValue(1.25)
panel.move_to_target_button.click()
move_callback.assert_called_once_with(1.25)
def test_alignment_controller_removes_deleted_dap_curve(qtbot, mocked_client):
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
controller.update_context(
AlignmentContext(visible=True, positioner_name="samx", precision=3, readback=1.0)
)
controller.update_dap_summary(make_alignment_fit_summary(center=1.5), {"curve_id": "fit"})
assert "fit" in panel.fit_dialog.summary_data
controller.remove_dap_curve("fit")
assert "fit" not in panel.fit_dialog.summary_data
assert panel.fit_dialog.fit_curve_id is None
assert panel.fit_dialog.enable_actions is False

View File

@@ -0,0 +1,40 @@
from unittest.mock import MagicMock
from bec_widgets.widgets.plots.waveform.utils.alignment_panel import WaveformAlignmentPanel
from .client_mocks import mocked_client
from .conftest import create_widget
def test_alignment_panel_forwards_position_and_target_signals(qtbot, mocked_client):
panel = create_widget(qtbot, WaveformAlignmentPanel, client=mocked_client)
position_callback = MagicMock()
target_toggle_callback = MagicMock()
target_move_callback = MagicMock()
panel.position_readback_changed.connect(position_callback)
panel.target_toggled.connect(target_toggle_callback)
panel.target_move_requested.connect(target_move_callback)
panel.positioner.position_update.emit(1.25)
panel.target_toggle.setChecked(True)
panel.move_to_target_button.setEnabled(True)
panel.move_to_target_button.click()
position_callback.assert_called_once_with(1.25)
target_toggle_callback.assert_called_once_with(True)
target_move_callback.assert_called_once_with()
def test_alignment_panel_emits_only_center_fit_actions(qtbot, mocked_client):
panel = create_widget(qtbot, WaveformAlignmentPanel, client=mocked_client)
fit_center_callback = MagicMock()
panel.fit_center_requested.connect(fit_center_callback)
panel.fit_dialog.move_action.emit(("sigma", 0.5))
fit_center_callback.assert_not_called()
panel.fit_dialog.move_action.emit(("center", 2.5))
fit_center_callback.assert_called_once_with(2.5)

View File

@@ -81,12 +81,95 @@ class _FakeReply:
self.deleted = True
@pytest.fixture
def experiment_info_message() -> ExperimentInfoMessage:
data = {
"_id": "p22622",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_xda_bs", "p22622"],
"realm_id": "TestBeamline",
"proposal": "12345967",
"title": "Test Experiment for Mat Card Widget",
"firstname": "John",
"lastname": "Doe",
"email": "john.doe@psi.ch",
"account": "doe_j",
"pi_firstname": "Jane",
"pi_lastname": "Smith",
"pi_email": "jane.smith@psi.ch",
"pi_account": "smith_j",
"eaccount": "e22622",
"pgroup": "p22622",
"abstract": "This is a test abstract for the experiment mat card widget. It should be long enough to test text wrapping and display in the card. The abstract provides a brief overview of the experiment, its goals, and its significance. This text is meant to simulate a real abstract that might be associated with an experiment in the BEC Atlas system. The card should be able to handle abstracts of varying lengths without any issues, ensuring that the user can read the full abstract even if it is quite long.",
"schedule": [{"start": "01/01/2025 08:00:00", "end": "03/01/2025 18:00:00"}],
"proposal_submitted": "15/12/2024",
"proposal_expire": "31/12/2025",
"proposal_status": "Scheduled",
"delta_last_schedule": 30,
"mainproposal": "",
}
return ExperimentInfoMessage.model_validate(data)
@pytest.fixture
def experiment_info_list(experiment_info_message: ExperimentInfoMessage) -> list[dict]:
"""Fixture to provide a list of experiment info dictionaries."""
another_experiment_info = {
"_id": "p22623",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_xda_bs", "p22623"],
"realm_id": "TestBeamline",
"proposal": "",
"title": "Experiment without Proposal",
"firstname": "Alice",
"lastname": "Johnson",
"email": "alice.johnson@psi.ch",
"account": "johnson_a",
"pi_firstname": "Bob",
"pi_lastname": "Brown",
"pi_email": "bob.brown@psi.ch",
"pi_account": "brown_b",
"eaccount": "e22623",
"pgroup": "p22623",
"abstract": "",
"schedule": [],
"proposal_submitted": "",
"proposal_expire": "",
"proposal_status": "",
"delta_last_schedule": None,
"mainproposal": "",
}
return [
experiment_info_message.model_dump(),
ExperimentInfoMessage.model_validate(another_experiment_info).model_dump(),
]
class TestBECAtlasHTTPService:
@pytest.fixture
def http_service(self, qtbot):
def deployment_info(
self, experiment_info_message: ExperimentInfoMessage
) -> DeploymentInfoMessage:
"""Fixture to provide a DeploymentInfoMessage instance."""
return DeploymentInfoMessage(
deployment_id="dep-1",
name="Test Deployment",
messaging_config=MessagingConfig(
signal=MessagingServiceScopeConfig(enabled=False),
teams=MessagingServiceScopeConfig(enabled=False),
scilog=MessagingServiceScopeConfig(enabled=False),
),
active_session=SessionInfoMessage(
experiment=experiment_info_message, name="Test Session"
),
)
@pytest.fixture
def http_service(self, deployment_info: DeploymentInfoMessage, qtbot):
"""Fixture to create a BECAtlasHTTPService instance."""
service = BECAtlasHTTPService(base_url="http://localhost:8000")
service._set_current_deployment_info(deployment_info)
qtbot.addWidget(service)
qtbot.waitExposed(service)
return service
@@ -224,7 +307,7 @@ class TestBECAtlasHTTPService:
assert http_service.auth_user_info.groups == {"operators", "staff"}
mock_get_deployment_info.assert_called_once_with(deployment_id="dep-1")
def test_handle_response_deployment_info(self, http_service, qtbot):
def test_handle_response_deployment_info(self, http_service: BECAtlasHTTPService, qtbot):
"""Test handling deployment info response"""
# Groups match: should emit authenticated signal with user info
@@ -268,6 +351,25 @@ class TestBECAtlasHTTPService:
mock_show_warning.assert_called_once()
mock_logout.assert_called_once()
def test_handle_response_deployment_info_admin_access(self, http_service, qtbot):
http_service._auth_user_info = AuthenticatedUserInfo(
email="alice@example.org",
exp=time.time() + 60,
groups={"operators"},
deployment_id="dep-1",
)
# Admin user should authenticate regardless of group membership
reply = _FakeReply(
request_url="http://localhost:8000/deployments/id?deployment_id=dep-1",
status=200,
payload=b'{"owner_groups": ["admin", "atlas_func_account"], "name": "Beamline Deployment"}',
)
with qtbot.waitSignal(http_service.authenticated, timeout=1000) as blocker:
http_service._handle_response(reply)
assert blocker.args[0]["email"] == "alice@example.org"
def test_handle_response_emits_http_response(self, http_service, qtbot):
"""Test that _handle_response emits the http_response signal with correct parameters for a generic response."""
reply = _FakeReply(
@@ -297,70 +399,6 @@ class TestBECAtlasHTTPService:
http_service._handle_response(reply, _override_slot_params={"raise_error": True})
@pytest.fixture
def experiment_info_message() -> ExperimentInfoMessage:
data = {
"_id": "p22622",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_xda_bs", "p22622"],
"realm_id": "TestBeamline",
"proposal": "12345967",
"title": "Test Experiment for Mat Card Widget",
"firstname": "John",
"lastname": "Doe",
"email": "john.doe@psi.ch",
"account": "doe_j",
"pi_firstname": "Jane",
"pi_lastname": "Smith",
"pi_email": "jane.smith@psi.ch",
"pi_account": "smith_j",
"eaccount": "e22622",
"pgroup": "p22622",
"abstract": "This is a test abstract for the experiment mat card widget. It should be long enough to test text wrapping and display in the card. The abstract provides a brief overview of the experiment, its goals, and its significance. This text is meant to simulate a real abstract that might be associated with an experiment in the BEC Atlas system. The card should be able to handle abstracts of varying lengths without any issues, ensuring that the user can read the full abstract even if it is quite long.",
"schedule": [{"start": "01/01/2025 08:00:00", "end": "03/01/2025 18:00:00"}],
"proposal_submitted": "15/12/2024",
"proposal_expire": "31/12/2025",
"proposal_status": "Scheduled",
"delta_last_schedule": 30,
"mainproposal": "",
}
return ExperimentInfoMessage.model_validate(data)
@pytest.fixture
def experiment_info_list(experiment_info_message: ExperimentInfoMessage) -> list[dict]:
"""Fixture to provide a list of experiment info dictionaries."""
another_experiment_info = {
"_id": "p22623",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_xda_bs", "p22623"],
"realm_id": "TestBeamline",
"proposal": "",
"title": "Experiment without Proposal",
"firstname": "Alice",
"lastname": "Johnson",
"email": "alice.johnson@psi.ch",
"account": "johnson_a",
"pi_firstname": "Bob",
"pi_lastname": "Brown",
"pi_email": "bob.brown@psi.ch",
"pi_account": "brown_b",
"eaccount": "e22623",
"pgroup": "p22623",
"abstract": "",
"schedule": [],
"proposal_submitted": "",
"proposal_expire": "",
"proposal_status": "",
"delta_last_schedule": None,
"mainproposal": "",
}
return [
experiment_info_message.model_dump(),
ExperimentInfoMessage.model_validate(another_experiment_info).model_dump(),
]
class TestBECAtlasExperimentSelection:
def test_format_name(self, experiment_info_message: ExperimentInfoMessage):
@@ -546,7 +584,7 @@ class TestBECAtlasAdminView:
def test_init_and_login(self, admin_view: BECAtlasAdminView, qtbot):
"""Test that the BECAtlasAdminView initializes correctly."""
# Check that the atlas URL is set correctly
assert admin_view._atlas_url == "https://bec-atlas-dev.psi.ch/api/v1"
assert admin_view._atlas_url == "https://bec-atlas-prod.psi.ch/api/v1"
# Test that clicking the login button emits the credentials_entered signal with the correct username and password
with mock.patch.object(admin_view.atlas_http_service, "login") as mock_login:

View File

@@ -0,0 +1,252 @@
from unittest import mock
import pytest
import shiboken6
from qtpy.QtCore import QEvent, QEventLoop, Qt
from qtpy.QtGui import QHideEvent, QShowEvent
from qtpy.QtTest import QTest
from qtpy.QtWidgets import QApplication, QWidget
import bec_widgets.widgets.editors.bec_console.bec_console as bec_console_module
from bec_widgets.widgets.editors.bec_console.bec_console import (
BecConsole,
BECShell,
ConsoleMode,
_bec_console_registry,
)
from .client_mocks import mocked_client
def process_deferred_deletes():
app = QApplication.instance()
app.sendPostedEvents(None, QEvent.DeferredDelete)
app.processEvents(QEventLoop.AllEvents)
@pytest.fixture(autouse=True)
def clean_bec_console_registry():
_bec_console_registry.clear()
yield
_bec_console_registry.clear()
process_deferred_deletes()
@pytest.fixture
def console_widget(qtbot):
"""Create a BecConsole widget."""
widget = BecConsole(client=mocked_client, gui_id="test_console", terminal_id="test_terminal")
qtbot.addWidget(widget)
return widget
@pytest.fixture
def two_console_widgets_same_terminal(qtbot):
widget1 = BecConsole(client=mocked_client, gui_id="console_1", terminal_id="shared_terminal")
widget2 = BecConsole(client=mocked_client, gui_id="console_2", terminal_id="shared_terminal")
qtbot.addWidget(widget1)
qtbot.addWidget(widget2)
return widget1, widget2
def test_bec_console_initialization(console_widget: BecConsole):
assert console_widget.console_id == "test_console"
assert console_widget.terminal_id == "test_terminal"
assert console_widget._mode == ConsoleMode.ACTIVE
assert console_widget.term is not None
assert console_widget._overlay.isHidden()
console_widget.show()
assert console_widget.isVisible()
assert _bec_console_registry.owner_is_visible(console_widget.terminal_id)
def test_bec_console_yield_terminal_ownership(console_widget):
console_widget.show()
console_widget.take_terminal_ownership()
console_widget.yield_ownership()
assert console_widget.term is None
assert console_widget._mode == ConsoleMode.INACTIVE
def test_bec_console_hide_event_yields_ownership(console_widget):
console_widget.take_terminal_ownership()
console_widget.hideEvent(QHideEvent())
assert console_widget.term is None
assert console_widget._mode == ConsoleMode.HIDDEN
def test_bec_console_show_event_takes_ownership(console_widget):
console_widget.yield_ownership()
console_widget.showEvent(QShowEvent())
assert console_widget.term is not None
assert console_widget._mode == ConsoleMode.ACTIVE
def test_bec_console_overlay_click_takes_ownership(qtbot, console_widget):
console_widget.yield_ownership()
assert console_widget._mode == ConsoleMode.HIDDEN
QTest.mouseClick(console_widget._overlay, Qt.LeftButton)
assert console_widget.term is not None
assert console_widget._mode == ConsoleMode.ACTIVE
assert not console_widget._overlay.isVisible()
def test_two_consoles_shared_terminal(two_console_widgets_same_terminal):
widget1, widget2 = two_console_widgets_same_terminal
# Widget1 takes ownership
widget1.take_terminal_ownership()
assert widget1.term is not None
assert widget1._mode == ConsoleMode.ACTIVE
assert widget2.term is None
assert widget2._mode == ConsoleMode.HIDDEN
# Widget2 takes ownership
widget2.take_terminal_ownership()
assert widget2.term is not None
assert widget2._mode == ConsoleMode.ACTIVE
assert widget1.term is None
assert widget1._mode == ConsoleMode.HIDDEN
def test_bec_console_registry_cleanup(console_widget: BecConsole):
console_widget.take_terminal_ownership()
terminal_id = console_widget.terminal_id
assert terminal_id in _bec_console_registry._terminal_registry
_bec_console_registry.unregister(console_widget)
assert terminal_id not in _bec_console_registry._terminal_registry
def test_bec_shell_initialization(qtbot):
widget = BECShell(gui_id="bec_shell")
qtbot.addWidget(widget)
assert widget.console_id == "bec_shell"
assert widget.terminal_id == "bec_shell"
assert widget.startup_cmd is not None
def test_bec_console_write(console_widget):
console_widget.take_terminal_ownership()
with mock.patch.object(console_widget.term, "write") as mock_write:
console_widget.write("test command")
mock_write.assert_called_once_with("test command", True)
def test_is_owner(console_widget: BecConsole):
assert _bec_console_registry.is_owner(console_widget)
mock_console = mock.MagicMock()
mock_console.console_id = "fake_console"
_bec_console_registry._consoles["fake_console"] = mock_console
assert not _bec_console_registry.is_owner(mock_console)
mock_console.terminal_id = console_widget.terminal_id
assert not _bec_console_registry.is_owner(mock_console)
def test_closing_active_console_keeps_terminal_valid_for_remaining_console(qtbot):
widget1 = BecConsole(client=mocked_client, gui_id="close_owner", terminal_id="shared_close")
widget2 = BecConsole(client=mocked_client, gui_id="remaining", terminal_id="shared_close")
qtbot.addWidget(widget2)
widget1.take_terminal_ownership()
term = widget1.term
assert term is not None
widget1.close()
widget1.deleteLater()
process_deferred_deletes()
assert shiboken6.isValid(term)
widget2.take_terminal_ownership()
assert widget2.term is term
assert widget2._mode == ConsoleMode.ACTIVE
def test_active_console_detaches_terminal_before_destruction(qtbot):
widget1 = BecConsole(client=mocked_client, gui_id="owner", terminal_id="shared_detach")
widget2 = BecConsole(client=mocked_client, gui_id="survivor", terminal_id="shared_detach")
qtbot.addWidget(widget1)
qtbot.addWidget(widget2)
widget1.take_terminal_ownership()
term = widget1.term
assert term is not None
assert widget1.isAncestorOf(term)
widget1.close()
assert shiboken6.isValid(term)
assert not widget1.isAncestorOf(term)
assert term.parent() is widget2._term_holder
def test_bec_shell_terminal_persists_after_last_shell_unregisters(qtbot):
shell = BECShell(gui_id="bec_shell_persistent")
qtbot.addWidget(shell)
term = shell.term
assert term is not None
_bec_console_registry.unregister(shell)
info = _bec_console_registry._terminal_registry["bec_shell"]
assert info.registered_console_ids == set()
assert info.owner_console_id is None
assert info.persist_session is True
assert info.instance is term
assert shiboken6.isValid(term)
def test_new_bec_shell_claims_preserved_terminal(qtbot):
shell1 = BECShell(gui_id="bec_shell_first")
term = shell1.term
assert term is not None
shell1.close()
shell1.deleteLater()
process_deferred_deletes()
assert "bec_shell" in _bec_console_registry._terminal_registry
assert shiboken6.isValid(term)
shell2 = BECShell(gui_id="bec_shell_second")
qtbot.addWidget(shell2)
shell2.showEvent(QShowEvent())
assert shell2.term is term
assert shell2._mode == ConsoleMode.ACTIVE
def test_persistent_bec_shell_sends_startup_command_once(qtbot, monkeypatch):
class RecordingTerminal(QWidget):
writes = []
def write(self, text: str, add_newline: bool = True):
self.writes.append((text, add_newline))
monkeypatch.setattr(bec_console_module, "_BecTermClass", RecordingTerminal)
shell1 = BECShell(gui_id="bec_shell_startup_first")
shell1.close()
shell1.deleteLater()
process_deferred_deletes()
shell2 = BECShell(gui_id="bec_shell_startup_second")
qtbot.addWidget(shell2)
shell2.showEvent(QShowEvent())
assert len(RecordingTerminal.writes) == 1
assert RecordingTerminal.writes[0][0].startswith("bec ")
assert RecordingTerminal.writes[0][1] is True
def test_plain_console_terminal_removed_after_last_unregister(qtbot):
widget = BecConsole(client=mocked_client, gui_id="plain_console", terminal_id="plain_terminal")
qtbot.addWidget(widget)
assert "plain_terminal" in _bec_console_registry._terminal_registry
_bec_console_registry.unregister(widget)
assert "plain_terminal" not in _bec_console_registry._terminal_registry

View File

@@ -918,7 +918,7 @@ class TestToolbarFunctionality:
action.trigger()
if action_name == "terminal":
mock_new.assert_called_once_with(
widget="WebConsole", closable=True, startup_cmd=None
widget="BecConsole", closable=True, startup_cmd=None
)
else:
mock_new.assert_called_once_with(widget=widget_type)
@@ -2272,7 +2272,7 @@ class TestFlatToolbarActions:
"flat_queue": "BECQueue",
"flat_status": "BECStatusBox",
"flat_progress_bar": "RingProgressBar",
"flat_terminal": "WebConsole",
"flat_terminal": "BecConsole",
"flat_bec_shell": "BECShell",
"flat_sbb_monitor": "SBBMonitor",
}

View File

@@ -1,14 +1,18 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
import json
from unittest.mock import MagicMock
import pytest
from bec_lib.endpoints import MessageEndpoints
from pydantic import ValidationError
from qtpy.QtGui import QColor
from qtpy.QtCore import QEvent, QPoint, QPointF, Qt
from qtpy.QtGui import QColor, QMouseEvent
from qtpy.QtWidgets import QApplication
from bec_widgets.utils import Colors
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,
RingProgressContainerWidget,
)
from .client_mocks import mocked_client
@@ -432,8 +436,6 @@ def test_gap_affects_ring_positioning(ring_progress_bar):
for _ in range(3):
ring_progress_bar.add_ring()
initial_gap = ring_progress_bar.gap
# Change gap
new_gap = 30
ring_progress_bar.set_gap(new_gap)
@@ -467,3 +469,275 @@ def test_rings_property_returns_correct_list(ring_progress_bar):
# Should return the same list
assert rings_via_property is rings_direct
assert len(rings_via_property) == 3
###################################
# Hover behavior tests
###################################
@pytest.fixture
def container(qtbot):
widget = RingProgressContainerWidget()
qtbot.addWidget(widget)
widget.resize(200, 200)
yield widget
def _ring_center_pos(container):
"""Return (center_x, center_y, base_radius) for a square container."""
size = min(container.width(), container.height())
center_x = container.width() / 2
center_y = container.height() / 2
max_ring_size = container.get_max_ring_size()
base_radius = (size - 2 * max_ring_size) / 2
return center_x, center_y, base_radius
def _send_mouse_move(widget, pos: QPoint):
global_pos = widget.mapToGlobal(pos)
event = QMouseEvent(
QEvent.Type.MouseMove,
QPointF(pos),
QPointF(global_pos),
Qt.MouseButton.NoButton,
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
)
QApplication.sendEvent(widget, event)
def test_ring_at_pos_no_rings(container):
assert container._ring_at_pos(QPointF(100, 100)) is None
def test_ring_at_pos_center_is_inside_rings(container):
"""The center of the widget is inside all rings; _ring_at_pos should return None."""
container.add_ring()
assert container._ring_at_pos(QPointF(100, 100)) is None
def test_ring_at_pos_on_single_ring(container):
"""A point on the ring arc should resolve to that ring."""
ring = container.add_ring()
cx, cy, base_radius = _ring_center_pos(container)
# Point exactly on the ring centerline
pos = QPointF(cx + base_radius, cy)
assert container._ring_at_pos(pos) is ring
def test_ring_at_pos_outside_all_rings(container):
"""A point well outside the outermost ring returns None."""
container.add_ring()
cx, cy, base_radius = _ring_center_pos(container)
line_width = container.rings[0].config.line_width
# Place point clearly beyond the outer edge
pos = QPointF(cx + base_radius + line_width + 5, cy)
assert container._ring_at_pos(pos) is None
def test_ring_at_pos_selects_correct_ring_from_multiple(qtbot):
"""With multiple rings, each position resolves to the right ring."""
container = RingProgressContainerWidget()
qtbot.addWidget(container)
container.resize(300, 300)
ring0 = container.add_ring()
ring1 = container.add_ring()
size = min(container.width(), container.height())
cx = container.width() / 2
cy = container.height() / 2
max_ring_size = container.get_max_ring_size()
base_radius = (size - 2 * max_ring_size) / 2
radius0 = base_radius - ring0.gap
radius1 = base_radius - ring1.gap
assert container._ring_at_pos(QPointF(cx + radius0, cy)) is ring0
assert container._ring_at_pos(QPointF(cx + radius1, cy)) is ring1
def test_set_hovered_ring_sets_flag(container):
"""_set_hovered_ring marks the ring as hovered and updates _hovered_ring."""
ring = container.add_ring()
assert container._hovered_ring is None
container._set_hovered_ring(ring)
assert container._hovered_ring is ring
assert ring._hovered is True
def test_set_hovered_ring_to_none_clears_flag(container):
"""Calling _set_hovered_ring(None) un-hovers the current ring."""
ring = container.add_ring()
container._set_hovered_ring(ring)
container._set_hovered_ring(None)
assert container._hovered_ring is None
assert ring._hovered is False
def test_set_hovered_ring_switches_between_rings(qtbot):
"""Switching hover from one ring to another correctly updates both flags."""
container = RingProgressContainerWidget()
qtbot.addWidget(container)
ring0 = container.add_ring()
ring1 = container.add_ring()
container._set_hovered_ring(ring0)
assert ring0._hovered is True
assert ring1._hovered is False
container._set_hovered_ring(ring1)
assert ring0._hovered is False
assert ring1._hovered is True
assert container._hovered_ring is ring1
def test_build_tooltip_text_manual_mode(container):
"""Manual mode tooltip contains mode label, value, max and percentage."""
ring = container.add_ring()
ring.set_value(50)
ring.set_min_max_values(0, 100)
text = RingProgressContainerWidget._build_tooltip_text(ring)
assert "Manual" in text
assert "50.0%" in text
assert "100" in text
def test_build_tooltip_text_scan_mode(container):
"""Scan mode tooltip labels the mode as 'Scan progress'."""
ring = container.add_ring()
ring.config.mode = "scan"
ring.set_value(25)
text = RingProgressContainerWidget._build_tooltip_text(ring)
assert "Scan progress" in text
def test_build_tooltip_text_device_mode_with_signal(container):
"""Device mode tooltip shows device:signal when both are set."""
ring = container.add_ring()
ring.config.mode = "device"
ring.config.device = "samx"
ring.config.signal = "readback"
ring.set_value(10)
text = RingProgressContainerWidget._build_tooltip_text(ring)
assert "Device" in text
assert "samx:readback" in text
def test_build_tooltip_text_device_mode_without_signal(container):
"""Device mode tooltip shows only device name when signal is absent."""
ring = container.add_ring()
ring.config.mode = "device"
ring.config.device = "samy"
ring.config.signal = None
ring.set_value(10)
text = RingProgressContainerWidget._build_tooltip_text(ring)
assert "samy" in text
assert ":" not in text.split("Device:")[-1].split("\n")[0]
def test_build_tooltip_text_nonzero_min_shows_range(container):
"""Tooltip includes Range line when min_value is not 0."""
ring = container.add_ring()
ring.set_min_max_values(10, 90)
ring.set_value(50)
text = RingProgressContainerWidget._build_tooltip_text(ring)
assert "Range" in text
def test_build_tooltip_text_zero_min_no_range(container):
"""Tooltip omits Range line when min_value is 0."""
ring = container.add_ring()
ring.set_min_max_values(0, 100)
ring.set_value(50)
text = RingProgressContainerWidget._build_tooltip_text(ring)
assert "Range" not in text
def test_refresh_hover_tooltip_updates_label_on_value_change(container):
"""refresh_hover_tooltip updates the label text after the ring value changes."""
ring = container.add_ring()
ring.set_value(30)
container._hovered_ring = ring
container._last_hover_global_pos = QPoint(100, 100)
container.refresh_hover_tooltip(ring)
text_before = container._hover_tooltip_label.text()
ring.set_value(70)
container.refresh_hover_tooltip(ring)
text_after = container._hover_tooltip_label.text()
assert text_before != text_after
assert "70" in text_after
def test_refresh_hover_tooltip_no_pos_does_not_crash(container):
"""refresh_hover_tooltip with no stored position should return without raising."""
ring = container.add_ring()
container._last_hover_global_pos = None
# Should not raise
container.refresh_hover_tooltip(ring)
def test_mouse_move_sets_hovered_ring_and_updates_tooltip(qtbot, container):
ring = container.add_ring()
container._hover_tooltip.show_near = MagicMock()
container.show()
qtbot.waitExposed(container)
cx, cy, base_radius = _ring_center_pos(container)
_send_mouse_move(container, QPoint(int(cx + base_radius), int(cy)))
assert container._hovered_ring is ring
assert ring._hovered is True
assert "Mode: Manual" in container._hover_tooltip_label.text()
container._hover_tooltip.show_near.assert_called_once()
def test_mouse_move_switches_hover_between_rings(qtbot):
container = RingProgressContainerWidget()
qtbot.addWidget(container)
container.resize(300, 300)
ring0 = container.add_ring()
ring1 = container.add_ring()
container._hover_tooltip.show_near = MagicMock()
container.show()
qtbot.waitExposed(container)
cx, cy, base_radius = _ring_center_pos(container)
radius0 = base_radius - ring0.gap
radius1 = base_radius - ring1.gap
_send_mouse_move(container, QPoint(int(cx + radius0), int(cy)))
assert container._hovered_ring is ring0
_send_mouse_move(container, QPoint(int(cx + radius1), int(cy)))
assert container._hovered_ring is ring1
assert ring0._hovered is False
assert ring1._hovered is True
def test_leave_event_clears_hover_and_hides_tooltip(qtbot, container):
ring = container.add_ring()
container._hover_tooltip.hide = MagicMock()
container.show()
qtbot.waitExposed(container)
cx, cy, base_radius = _ring_center_pos(container)
_send_mouse_move(container, QPoint(int(cx + base_radius), int(cy)))
QApplication.sendEvent(container, QEvent(QEvent.Type.Leave))
assert container._hovered_ring is None
assert ring._hovered is False
assert container._last_hover_global_pos is None
container._hover_tooltip.hide.assert_called()

View File

@@ -240,6 +240,36 @@ def test_set_start_angle(ring_widget):
assert ring_widget.config.start_position == 180
def test_set_hovered_updates_animation_target(ring_widget):
ring_widget.set_hovered(True)
assert ring_widget._hovered is True
assert ring_widget._hover_animation.endValue() == 1.0
ring_widget.set_hovered(False)
assert ring_widget._hovered is False
assert ring_widget._hover_animation.endValue() == 0.0
def test_refresh_hover_tooltip_delegates_to_container(ring_widget):
ring_widget.progress_container = MagicMock()
ring_widget.progress_container.is_ring_hovered.return_value = True
ring_widget._request_update()
ring_widget.progress_container.refresh_hover_tooltip.assert_called_once_with(ring_widget)
def test_refresh_hover_tooltip_skips_when_ring_is_not_hovered(ring_widget):
ring_widget.progress_container = MagicMock()
ring_widget.progress_container.is_ring_hovered.return_value = False
ring_widget._request_update()
ring_widget.progress_container.refresh_hover_tooltip.assert_not_called()
###################################
# Color management tests
###################################

View File

@@ -36,6 +36,22 @@ from .conftest import create_widget
##################################################
def make_alignment_fit_summary(center: float | None = None) -> dict:
params = []
if center is not None:
params.append(["center", center, True, None, -np.inf, np.inf, None, 0.1, {}, 0.0, None])
params.append(["sigma", 0.5, True, None, 0.0, np.inf, None, 0.1, {}, 1.0, None])
return {
"model": "Model(test)",
"method": "leastsq",
"chisqr": 1.0,
"redchi": 1.0,
"rsquared": 0.99,
"message": "Fit succeeded.",
"params": params,
}
def test_waveform_initialization(qtbot, mocked_client):
"""
Test that a new Waveform widget initializes with the correct defaults.
@@ -496,6 +512,218 @@ def test_add_dap_curve_custom_source(qtbot, mocked_client_with_dap):
assert dap_curve.config.signal.dap == "GaussianModel"
def test_alignment_mode_toggle_shows_bottom_panel(qtbot, mocked_client):
wf = create_widget(qtbot, Waveform, client=mocked_client)
action = wf.toolbar.components.get_action("alignment_mode").action
action.trigger()
assert wf._alignment_panel_visible is True
assert wf._alignment_side_panel.panel_visible is True
assert action.isChecked() is True
action.trigger()
assert wf._alignment_panel_visible is False
assert wf._alignment_side_panel.panel_visible is False
assert action.isChecked() is False
def test_resolve_alignment_positioner(qtbot, mocked_client):
wf = create_widget(qtbot, Waveform, client=mocked_client)
wf.x_mode = "samx"
assert wf._resolve_alignment_positioner() == "samx"
wf.x_mode = "auto"
wf._current_x_device = ("samx", "samx")
assert wf._resolve_alignment_positioner() == "samx"
wf._current_x_device = ("bpm4i", "bpm4i")
assert wf._resolve_alignment_positioner() is None
wf.x_mode = "index"
assert wf._resolve_alignment_positioner() is None
wf.x_mode = "timestamp"
assert wf._resolve_alignment_positioner() is None
def test_alignment_panel_updates_when_auto_x_motor_changes(
qtbot, mocked_client_with_dap, monkeypatch
):
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
wf.plot(arg1="bpm4i", dap="GaussianModel")
wf.x_mode = "auto"
wf.toolbar.components.get_action("alignment_mode").action.trigger()
wf._current_x_device = ("samx", "samx")
wf._alignment_panel.set_positioner_device("samx")
wf.scan_item = create_dummy_scan_item()
wf.scan_item.metadata["bec"]["scan_report_devices"] = ["samy"]
data = {
"samy": {"samy": {"val": np.array([1.0, 2.0, 3.0])}},
"bpm4i": {"bpm4i": {"val": np.array([10.0, 20.0, 30.0])}},
}
monkeypatch.setattr(wf, "_fetch_scan_data_and_access", lambda: (data, "val"))
wf._get_x_data("bpm4i", "bpm4i")
assert wf._current_x_device == ("samy", "samy")
assert wf._alignment_positioner_name == "samy"
assert wf._alignment_panel.positioner.device == "samy"
def test_alignment_panel_disables_without_positioner(qtbot, mocked_client_with_dap):
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
wf.plot(arg1="bpm4i")
wf.x_mode = "index"
wf.toolbar.components.get_action("alignment_mode").action.trigger()
assert wf._alignment_panel.positioner.isEnabled() is False
assert "positioner on the x axis" in wf._alignment_panel.status_label.text()
def test_alignment_marker_updates_from_positioner_readback(qtbot, mocked_client_with_dap):
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
wf.plot(arg1="bpm4i", dap="GaussianModel")
wf.x_mode = "samx"
wf.toolbar.components.get_action("alignment_mode").action.trigger()
wf.dev["samx"].signals["samx"]["value"] = 4.2
wf._alignment_panel.positioner.force_update_readback()
assert wf._alignment_controller is not None
assert wf._alignment_controller.marker_line is not None
assert np.isclose(wf._alignment_controller.marker_line.value(), 4.2)
assert "samx" in wf._alignment_controller.marker_line.label.toPlainText()
assert "4.200" in wf._alignment_controller.marker_line.label.toPlainText()
def test_alignment_panel_uses_existing_dap_curves_and_moves_positioner(
qtbot, mocked_client_with_dap
):
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
source_curve = wf.plot(arg1="bpm4i")
dap_curve = wf.add_dap_curve(device_label=source_curve.name(), dap_name="GaussianModel")
wf.x_mode = "samx"
wf.toolbar.components.get_action("alignment_mode").action.trigger()
fit_summary = make_alignment_fit_summary(center=2.5)
wf.dap_summary_update.emit(fit_summary, {"curve_id": dap_curve.name()})
wf._alignment_panel.fit_dialog.select_curve(dap_curve.name())
move_spy = MagicMock()
wf.dev["samx"].move = move_spy
assert wf._alignment_panel.fit_dialog.fit_curve_id == dap_curve.name()
assert wf._alignment_panel.fit_dialog.action_buttons["center"].isEnabled() is True
wf._alignment_panel.fit_dialog.action_buttons["center"].click()
move_spy.assert_called_once_with(2.5, relative=False)
def test_alignment_target_line_toggle_updates_target_value_label(qtbot, mocked_client_with_dap):
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
wf.plot(arg1="bpm4i", dap="GaussianModel")
wf.x_mode = "samx"
wf.toolbar.components.get_action("alignment_mode").action.trigger()
wf._alignment_panel.target_toggle.setChecked(True)
assert wf._alignment_controller is not None
assert wf._alignment_controller.target_line is not None
assert wf._alignment_panel.move_to_target_button.isEnabled() is True
wf._alignment_controller.target_line.setValue(1.5)
assert "1.500" in wf._alignment_panel.target_toggle.text()
def test_alignment_move_to_target_uses_draggable_line_value(qtbot, mocked_client_with_dap):
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
wf.plot(arg1="bpm4i", dap="GaussianModel")
wf.x_mode = "samx"
wf.toolbar.components.get_action("alignment_mode").action.trigger()
wf._alignment_panel.target_toggle.setChecked(True)
wf._alignment_controller.target_line.setValue(1.25)
move_spy = MagicMock()
wf.dev["samx"].move = move_spy
wf._alignment_panel.move_to_target_button.click()
move_spy.assert_called_once_with(1.25, relative=False)
def test_alignment_mode_toggle_off_keeps_user_dap_curve(qtbot, mocked_client_with_dap):
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
source_curve = wf.plot(arg1="bpm4i")
dap_curve = wf.add_dap_curve(device_label=source_curve.name(), dap_name="GaussianModel")
wf.x_mode = "samx"
action = wf.toolbar.components.get_action("alignment_mode").action
action.trigger()
action.trigger()
assert wf.get_curve(dap_curve.name()) is not None
def test_alignment_mode_toggle_off_clears_controller_overlays(qtbot, mocked_client_with_dap):
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
wf.plot(arg1="bpm4i", dap="GaussianModel")
wf.x_mode = "samx"
action = wf.toolbar.components.get_action("alignment_mode").action
action.trigger()
wf._alignment_panel.target_toggle.setChecked(True)
wf.dev["samx"].signals["samx"]["value"] = 2.0
wf._alignment_panel.positioner.force_update_readback()
assert wf._alignment_controller.marker_line is not None
assert wf._alignment_controller.target_line is not None
action.trigger()
assert wf._alignment_controller.marker_line is None
assert wf._alignment_controller.target_line is None
def test_alignment_panel_removes_deleted_dap_curve_from_fit_list(qtbot, mocked_client_with_dap):
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
source_curve = wf.plot(arg1="bpm4i")
dap_curve = wf.add_dap_curve(device_label=source_curve.name(), dap_name="GaussianModel")
wf.toolbar.components.get_action("alignment_mode").action.trigger()
wf.dap_summary_update.emit(
make_alignment_fit_summary(center=1.5), {"curve_id": dap_curve.name()}
)
assert dap_curve.name() in wf._alignment_panel.fit_dialog.summary_data
wf.remove_curve(dap_curve.name())
assert dap_curve.name() not in wf._alignment_panel.fit_dialog.summary_data
def test_alignment_controller_move_request_moves_positioner(qtbot, mocked_client_with_dap):
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
wf.plot(arg1="bpm4i", dap="GaussianModel")
wf.x_mode = "samx"
move_spy = MagicMock()
wf.dev["samx"].move = move_spy
wf.toolbar.components.get_action("alignment_mode").action.trigger()
wf._alignment_controller.move_absolute_requested.emit(3.5)
move_spy.assert_called_once_with(3.5, relative=False)
def test_curve_set_data_emits_dap_update(qtbot, mocked_client):
wf = create_widget(qtbot, Waveform, client=mocked_client)
c = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="test_curve")

View File

@@ -1,476 +0,0 @@
from unittest import mock
import pytest
from qtpy.QtCore import Qt
from qtpy.QtGui import QHideEvent
from qtpy.QtNetwork import QAuthenticator
from bec_widgets.widgets.editors.web_console.web_console import (
BECShell,
ConsoleMode,
WebConsole,
_web_console_registry,
)
from .client_mocks import mocked_client
@pytest.fixture
def mocked_server_startup():
"""Mock the web console server startup process."""
with mock.patch(
"bec_widgets.widgets.editors.web_console.web_console.subprocess"
) as mock_subprocess:
with mock.patch.object(_web_console_registry, "_wait_for_server_port"):
_web_console_registry._server_port = 12345
yield mock_subprocess
def static_console(qtbot, client, unique_id: str | None = None):
"""Fixture to provide a static unique_id for WebConsole tests."""
if unique_id is None:
widget = WebConsole(client=client)
else:
widget = WebConsole(client=client, unique_id=unique_id)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
return widget
@pytest.fixture
def console_widget(qtbot, mocked_client, mocked_server_startup):
"""Create a WebConsole widget with mocked server startup."""
yield static_console(qtbot, mocked_client)
@pytest.fixture
def bec_shell_widget(qtbot, mocked_client, mocked_server_startup):
"""Create a BECShell widget with mocked server startup."""
widget = BECShell(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@pytest.fixture
def console_widget_with_static_id(qtbot, mocked_client, mocked_server_startup):
"""Create a WebConsole widget with a static unique ID."""
yield static_console(qtbot, mocked_client, unique_id="test_console")
@pytest.fixture
def two_console_widgets_same_id(qtbot, mocked_client, mocked_server_startup):
"""Create two WebConsole widgets sharing the same unique ID."""
widget1 = static_console(qtbot, mocked_client, unique_id="shared_console")
widget2 = static_console(qtbot, mocked_client, unique_id="shared_console")
yield widget1, widget2
def test_web_console_widget_initialization(console_widget):
assert (
console_widget.page.url().toString()
== f"http://localhost:{_web_console_registry._server_port}"
)
def test_web_console_write(console_widget):
# Test the write method
with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js:
console_widget.write("Hello, World!")
assert mock.call('window.term.paste("Hello, World!");') in mock_run_js.mock_calls
def test_web_console_write_no_return(console_widget):
# Test the write method with send_return=False
with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js:
console_widget.write("Hello, World!", send_return=False)
assert mock.call('window.term.paste("Hello, World!");') in mock_run_js.mock_calls
assert mock_run_js.call_count == 1
def test_web_console_send_return(console_widget):
# Test the send_return method
with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js:
console_widget.send_return()
script = mock_run_js.call_args[0][0]
assert "new KeyboardEvent('keypress', {charCode: 13})" in script
assert mock_run_js.call_count == 1
def test_web_console_send_ctrl_c(console_widget):
# Test the send_ctrl_c method
with mock.patch.object(console_widget.page, "runJavaScript") as mock_run_js:
console_widget.send_ctrl_c()
script = mock_run_js.call_args[0][0]
assert "new KeyboardEvent('keypress', {charCode: 3})" in script
assert mock_run_js.call_count == 1
def test_web_console_authenticate(console_widget):
# Test the _authenticate method
token = _web_console_registry._token
mock_auth = mock.MagicMock(spec=QAuthenticator)
console_widget._authenticate(None, mock_auth)
mock_auth.setUser.assert_called_once_with("user")
mock_auth.setPassword.assert_called_once_with(token)
def test_web_console_registry_wait_for_server_port():
# Test the _wait_for_server_port method
with mock.patch.object(_web_console_registry, "_server_process") as mock_subprocess:
mock_subprocess.stderr.readline.side_effect = [b"Starting", b"Listening on port: 12345"]
_web_console_registry._wait_for_server_port()
assert _web_console_registry._server_port == 12345
def test_web_console_registry_wait_for_server_port_timeout():
# Test the _wait_for_server_port method with timeout
with mock.patch.object(_web_console_registry, "_server_process") as mock_subprocess:
with pytest.raises(TimeoutError):
_web_console_registry._wait_for_server_port(timeout=0.1)
def test_web_console_startup_command_execution(console_widget, qtbot):
"""Test that the startup command is triggered after successful initialization."""
# Set a custom startup command
console_widget.startup_cmd = "test startup command"
assert console_widget.startup_cmd == "test startup command"
# Generator to simulate JS initialization sequence
def js_readiness_sequence():
yield False # First call: not ready yet
while True:
yield True # Any subsequent calls: ready
readiness_gen = js_readiness_sequence()
def mock_run_js(script, callback=None):
# Check if this is the initialization check call
if "window.term !== undefined" in script and callback:
ready = next(readiness_gen)
callback(ready)
else:
# For other JavaScript calls (like paste), just call the callback
if callback:
callback(True)
with mock.patch.object(
console_widget.page, "runJavaScript", side_effect=mock_run_js
) as mock_run_js_method:
# Reset initialization state and start the timer
console_widget._is_initialized = False
console_widget._startup_timer.start()
# Wait for the initialization to complete
qtbot.waitUntil(lambda: console_widget._is_initialized, timeout=3000)
# Verify that the startup command was executed
startup_calls = [
call
for call in mock_run_js_method.call_args_list
if "test startup command" in str(call)
]
assert len(startup_calls) > 0, "Startup command should have been executed"
# Verify the initialized signal was emitted
assert console_widget._is_initialized is True
assert not console_widget._startup_timer.isActive()
def test_bec_shell_startup_contains_gui_id(bec_shell_widget):
"""Test that the BEC shell startup command includes the GUI ID."""
bec_shell = bec_shell_widget
assert bec_shell._is_bec_shell
assert bec_shell._unique_id == "bec_shell"
with mock.patch.object(bec_shell.bec_dispatcher, "cli_server", None):
assert bec_shell.startup_cmd == "bec --nogui"
with mock.patch.object(bec_shell.bec_dispatcher.cli_server, "gui_id", "test_gui_id"):
assert bec_shell.startup_cmd == "bec --gui-id test_gui_id"
def test_web_console_set_readonly(console_widget):
# Test the set_readonly method
console_widget.set_readonly(True)
assert not console_widget.isEnabled()
console_widget.set_readonly(False)
assert console_widget.isEnabled()
def test_web_console_with_unique_id(console_widget_with_static_id):
"""Test creating a WebConsole with a unique_id."""
widget = console_widget_with_static_id
assert widget._unique_id == "test_console"
assert widget._unique_id in _web_console_registry._page_registry
page_info = _web_console_registry.get_page_info("test_console")
assert page_info is not None
assert page_info.owner_gui_id == widget.gui_id
assert widget.gui_id in page_info.widget_ids
def test_web_console_page_sharing(two_console_widgets_same_id):
"""Test that two widgets can share the same page using unique_id."""
widget1, widget2 = two_console_widgets_same_id
# Both should reference the same page in the registry
page_info = _web_console_registry.get_page_info("shared_console")
assert page_info is not None
assert widget1.gui_id in page_info.widget_ids
assert widget2.gui_id in page_info.widget_ids
assert widget1.page == widget2.page
def test_web_console_has_ownership(console_widget_with_static_id):
"""Test the has_ownership method."""
widget = console_widget_with_static_id
# Widget should have ownership by default
assert widget.has_ownership()
def test_web_console_yield_ownership(console_widget_with_static_id):
"""Test yielding ownership of a page."""
widget = console_widget_with_static_id
assert widget.has_ownership()
# Yield ownership
widget.yield_ownership()
# Widget should no longer have ownership
assert not widget.has_ownership()
page_info = _web_console_registry.get_page_info("test_console")
assert page_info.owner_gui_id is None
# Overlay should be shown
assert widget._mode == ConsoleMode.INACTIVE
def test_web_console_take_page_ownership(two_console_widgets_same_id):
"""Test taking ownership of a page."""
widget1, widget2 = two_console_widgets_same_id
# Widget1 should have ownership initially
assert widget1.has_ownership()
assert not widget2.has_ownership()
# Widget2 takes ownership
widget2.take_page_ownership()
# Now widget2 should have ownership
assert not widget1.has_ownership()
assert widget2.has_ownership()
assert widget2._mode == ConsoleMode.ACTIVE
assert widget1._mode == ConsoleMode.INACTIVE
def test_web_console_hide_event_yields_ownership(qtbot, console_widget_with_static_id):
"""Test that hideEvent yields ownership."""
widget = console_widget_with_static_id
assert widget.has_ownership()
# Hide the widget. Note that we cannot call widget.hide() directly
# because it doesn't trigger the hideEvent in tests as widgets are
# not visible in the test environment.
widget.hideEvent(QHideEvent())
qtbot.wait(100) # Allow event processing
# Widget should have yielded ownership
assert not widget.has_ownership()
page_info = _web_console_registry.get_page_info("test_console")
assert page_info.owner_gui_id is None
def test_web_console_show_event_takes_ownership(console_widget_with_static_id):
"""Test that showEvent takes ownership when page has no owner."""
widget = console_widget_with_static_id
# Yield ownership
widget.yield_ownership()
assert not widget.has_ownership()
# Show the widget again
widget.show()
# Widget should have reclaimed ownership
assert widget.has_ownership()
assert widget.browser.isVisible()
assert not widget.overlay.isVisible()
def test_web_console_mouse_press_takes_ownership(qtbot, two_console_widgets_same_id):
"""Test that clicking on overlay takes ownership."""
widget1, widget2 = two_console_widgets_same_id
widget1.show()
widget2.show()
# Widget1 has ownership, widget2 doesn't
assert widget1.has_ownership()
assert not widget2.has_ownership()
assert widget1.isVisible()
assert widget1._mode == ConsoleMode.ACTIVE
assert widget2._mode == ConsoleMode.INACTIVE
qtbot.mouseClick(widget2, Qt.MouseButton.LeftButton)
# Widget2 should now have ownership
assert widget2.has_ownership()
assert not widget1.has_ownership()
def test_web_console_registry_cleanup_removes_page(console_widget_with_static_id):
"""Test that the registry cleans up pages when all widgets are removed."""
widget = console_widget_with_static_id
assert widget._unique_id in _web_console_registry._page_registry
# Cleanup the widget
widget.cleanup()
# Page should be removed from registry
assert widget._unique_id not in _web_console_registry._page_registry
def test_web_console_without_unique_id_no_page_sharing(console_widget):
"""Test that widgets without unique_id don't participate in page sharing."""
widget = console_widget
# Widget should not be in the page registry
assert widget._unique_id is None
assert not widget.has_ownership() # Should return False for non-unique widgets
def test_web_console_registry_get_page_info_nonexistent(qtbot, mocked_client):
"""Test getting page info for a non-existent page."""
page_info = _web_console_registry.get_page_info("nonexistent")
assert page_info is None
def test_web_console_take_ownership_without_unique_id(console_widget):
"""Test that take_page_ownership fails gracefully without unique_id."""
widget = console_widget
# Should not crash when taking ownership without unique_id
widget.take_page_ownership()
def test_web_console_yield_ownership_without_unique_id(console_widget):
"""Test that yield_ownership fails gracefully without unique_id."""
widget = console_widget
# Should not crash when yielding ownership without unique_id
widget.yield_ownership()
def test_registry_yield_ownership_gui_id_not_in_instances():
"""Test registry yield_ownership returns False when gui_id not in instances."""
result = _web_console_registry.yield_ownership("nonexistent_gui_id")
assert result is False
def test_registry_yield_ownership_instance_is_none(console_widget_with_static_id):
"""Test registry yield_ownership returns False when instance weakref is dead."""
widget = console_widget_with_static_id
gui_id = widget.gui_id
# Store the gui_id and simulate the weakref being dead
_web_console_registry._instances[gui_id] = lambda: None
result = _web_console_registry.yield_ownership(gui_id)
assert result is False
def test_registry_yield_ownership_unique_id_none(console_widget_with_static_id):
"""Test registry yield_ownership returns False when page info's unique_id is None."""
widget = console_widget_with_static_id
gui_id = widget.gui_id
unique_id = widget._unique_id
widget._unique_id = None
result = _web_console_registry.yield_ownership(gui_id)
assert result is False
widget._unique_id = unique_id # Restore for cleanup
def test_registry_yield_ownership_unique_id_not_in_page_registry(console_widget_with_static_id):
"""Test registry yield_ownership returns False when unique_id not in page registry."""
widget = console_widget_with_static_id
gui_id = widget.gui_id
unique_id = widget._unique_id
widget._unique_id = "nonexistent_unique_id"
result = _web_console_registry.yield_ownership(gui_id)
assert result is False
widget._unique_id = unique_id # Restore for cleanup
def test_registry_owner_is_visible_page_info_none():
"""Test owner_is_visible returns False when page info doesn't exist."""
result = _web_console_registry.owner_is_visible("nonexistent_page")
assert result is False
def test_registry_owner_is_visible_no_owner(console_widget_with_static_id):
"""Test owner_is_visible returns False when page has no owner."""
widget = console_widget_with_static_id
# Yield ownership so there's no owner
widget.yield_ownership()
page_info = _web_console_registry.get_page_info(widget._unique_id)
assert page_info.owner_gui_id is None
result = _web_console_registry.owner_is_visible(widget._unique_id)
assert result is False
def test_registry_owner_is_visible_owner_ref_none(console_widget_with_static_id):
"""Test owner_is_visible returns False when owner ref doesn't exist in instances."""
widget = console_widget_with_static_id
unique_id = widget._unique_id
# Remove owner from instances dict
del _web_console_registry._instances[widget.gui_id]
result = _web_console_registry.owner_is_visible(unique_id)
assert result is False
def test_registry_owner_is_visible_owner_instance_none(console_widget_with_static_id):
"""Test owner_is_visible returns False when owner instance weakref is dead."""
widget = console_widget_with_static_id
unique_id = widget._unique_id
gui_id = widget.gui_id
# Simulate dead weakref
_web_console_registry._instances[gui_id] = lambda: None
result = _web_console_registry.owner_is_visible(unique_id)
assert result is False
def test_registry_owner_is_visible_owner_visible(console_widget_with_static_id):
"""Test owner_is_visible returns True when owner is visible."""
widget = console_widget_with_static_id
widget.show()
result = _web_console_registry.owner_is_visible(widget._unique_id)
assert result is True
def test_registry_owner_is_visible_owner_not_visible(console_widget_with_static_id):
"""Test owner_is_visible returns False when owner is not visible."""
widget = console_widget_with_static_id
widget.hide()
result = _web_console_registry.owner_is_visible(widget._unique_id)
assert result is False