migrate pyqt5 to pyside6

This commit is contained in:
ykcheungab
2026-09-21 09:34:56 +08:00
parent e276b5de0f
commit ccedb9ddd9
66 changed files with 2184 additions and 395 deletions
+6 -3
View File
@@ -29,7 +29,8 @@ Replaces the vendor example program it was ported from:
## Run it
PySide6 only — Qt's LGPLv3 binding, so an internal closed-source tool needs no
licence purchase. (`PyQt5`, which the studio app uses, is GPLv3-or-commercial.)
licence purchase. (PyQt5 is GPLv3-or-commercial; the studio app moved to PySide6
for the same reason and no longer uses it.)
```powershell
# with the environment that already has PySide6 (Anaconda py310, for example)
@@ -130,8 +131,10 @@ The suite is written for **PySide6**. Where PySide6 is missing, the test modules
are skipped rather than failed: a broken environment should not look like a
broken application. `tests/test_binding_purity.py` enforces the binding policy
statically — no module may import PyQt5/PySide2/PyQt6, no module may use an
unscoped Qt enumerator (removed in PySide6), `shared/` may import no binding at
all, and the controller interface must stay importable headless.
unscoped Qt enumerator (removed in PySide6), `shared/` may import PySide6 and no
other binding, and the controller interface must stay importable headless. The
studio app enforces the same rules for itself in
`apps/studio/tests/test_binding_purity.py`.
## Do not ship this app
+17 -2
View File
@@ -196,6 +196,8 @@
"validation_title": "Checks",
"points_title": "Calibration points",
"field_mode": "Surface mode",
"field_name": "Sequence name",
"field_note": "Note",
"field_pitch": "Pitch (mm)",
"field_margin": "Margin (mm)",
"field_radius": "Radius (mm)",
@@ -221,8 +223,11 @@
"btn_generate": "Generate points",
"btn_simulate": "Simulate",
"btn_dry_run": "Dry run (no contact)",
"btn_save": "Save sequence…",
"btn_load": "Load sequence…",
"btn_export": "Export sequence…",
"btn_import": "Import sequence…",
"import_export_tip": "Saved as a JSON document (.oryseq), reusable across machines; the format version is checked on import.",
"export_failed": "Export failed: {error}",
"import_failed": "Import failed: {error}",
"pending_tooltip": "This belongs to phase {phase} and is not implemented yet.",
"col_index": "#",
"col_x": "X (mm)",
@@ -234,6 +239,16 @@
"col_actions": "Actions",
"not_generated": "—",
"status_pending": "Pending",
"status_skipped": "Skipped",
"summary": "{count} points ({enabled} enabled) · {spans} · about {duration} (estimate)",
"summary_empty": "No points yet: generate or import to see the count and travel span.",
"dialog_export": "Export sequence",
"dialog_import": "Import sequence",
"filter_sequence": "Calibration sequence (*.oryseq *.json)",
"exported": "Sequence exported: {name}",
"imported": "Sequence imported: {name} ({count} points)",
"import_warnings": "Import reported {count} warning(s)",
"confirm_discard": "The current sequence has not been exported. Importing replaces it. Continue?",
"points_hint": "Coordinates are generated and checked per point; they are not typed. A typed coordinate is one nothing has checked, and one wrong number ruins a run."
},
"action": {
+17 -2
View File
@@ -196,6 +196,8 @@
"validation_title": "校验",
"points_title": "标定点列表",
"field_mode": "曲面模式",
"field_name": "序列名称",
"field_note": "备注",
"field_pitch": "点距 (mm)",
"field_margin": "边界余量 (mm)",
"field_radius": "曲面半径 (mm)",
@@ -221,8 +223,11 @@
"btn_generate": "生成标定点",
"btn_simulate": "仿真",
"btn_dry_run": "干跑(不接触)",
"btn_save": "保存序列…",
"btn_load": "载入序列…",
"btn_export": "导出序列…",
"btn_import": "导入序列…",
"import_export_tip": "序列以 JSON 文档保存(.oryseq),可跨机器复用;导入前会校验格式版本。",
"export_failed": "导出失败: {error}",
"import_failed": "导入失败: {error}",
"pending_tooltip": "该功能属于 {phase} 阶段,尚未实现。",
"col_index": "#",
"col_x": "X (mm)",
@@ -234,6 +239,16 @@
"col_actions": "操作",
"not_generated": "—",
"status_pending": "待测",
"status_skipped": "已跳过",
"summary": "{count} 点({enabled} 启用) · {spans} · 预计 {duration}(估计值)",
"summary_empty": "尚无标定点:生成或导入后显示点数与行程范围。",
"dialog_export": "导出序列",
"dialog_import": "导入序列",
"filter_sequence": "标定序列 (*.oryseq *.json)",
"exported": "已导出序列: {name}",
"imported": "已导入序列: {name}({count} 点)",
"import_warnings": "导入有 {count} 项提示",
"confirm_discard": "当前序列尚未导出,导入将替换它。是否继续?",
"points_hint": "坐标由网格生成并逐点校验,不接受手工输入:手工输入的坐标没有任何检查,一个错数字会毁掉整条序列。"
},
"action": {
+167
View File
@@ -0,0 +1,167 @@
# Calibration sequence format (`.oryseq`)
A calibration sequence is a small JSON document: the **profile** that generated
the points, plus the **points** themselves. It is written by
`导出序列…` in the Sequence tab and read by `导入序列…`.
Why JSON and not a binary container: a sequence is at most a few hundred points
of text. It is read by a human, diffed in review, and emailed to whoever runs the
bench. Binary would buy nothing and cost all three. (The *acquired data* of a run
is a different problem — per-point time series are large and will need a sidecar;
that decision belongs to workflow P8, not here.)
## Conventions
The format follows this repository's existing documents rather than inventing a
convention. Where it matches `session.json` and `force_calibration.json`, that is
deliberate:
| Convention | Value |
|---|---|
| Version field | `"format_version": "1.0"` — a dotted **string**, compared as a tuple |
| Identity | `"kind": "autocal.sequence"` — checked on import |
| Extension | `.oryseq` by convention; import accepts any `.json` |
| Encoding | UTF-8, `indent=2`, `ensure_ascii=False`, trailing newline |
| Write | temp file then `Path.replace` — a half-written sequence that still parses looks like a design |
## The document
```json
{
"format_version": "1.0",
"kind": "autocal.sequence",
"created_at": "2026-09-20T10:31:00+00:00",
"name": "5 点灵敏度扫描",
"note": "",
"versions": { "app": "0.1.0", "shared": "0.1.0", "shared_api": "0.1" },
"device": {
"sensor_type": "box",
"config_ref": "…\\config\\sensors\\box\\sensor.json",
"geometry_id": "finger-1.0.0",
"probe_tip_set": true
},
"frame": { "targets": "platform", "length_unit": "mm", "units_per_mm": 10.0 },
"profile": {
"mode": "flat", "pitch_mm": 2.0, "margin_mm": 1.0, "radius_mm": 0.0,
"clear_height_mm": 5.0, "approach_speed_mm_s": 2.0, "press_speed_mm_s": 0.5,
"press_depth_mm": 0.2, "dwell_s": 0.5, "settle_window_s": 0.5,
"repeats": 3, "order": "serpentine"
},
"points": [
{
"id": 1,
"target_mm": { "x": 0.0, "y": 0.0, "z": 12.4 },
"sensor_mm": { "x": 0.0, "y": 0.0, "z": 0.0 },
"clearance_mm": 5.0,
"enabled": true,
"note": ""
}
],
"summary": {
"count": 12, "enabled": 12,
"min_mm": { "x": 0.0, "y": 0.0, "z": 12.4 },
"max_mm": { "x": 10.0, "y": 4.0, "z": 12.4 },
"span_mm": { "x": 10.0, "y": 4.0, "z": 0.0 },
"pitch_mm": 2.0,
"estimated_duration_s": 380.0
}
}
```
## Fields
### `frame` — required
| Field | Meaning |
|---|---|
| `targets` | the frame `target_mm` is expressed in; today always `"platform"` |
| `length_unit` | `"mm"`; anything else is refused |
| `units_per_mm` | the pulse equivalent the points were built with |
A sequence whose numbers do not say whether they are millimetres or user units is
a sequence that will eventually be read wrong. `units_per_mm` is recorded so a
file from a differently-configured axis is detectable rather than silently
believed.
### `profile` — required
The twelve fields the Sequence tab edits, and nothing else. A field absent from
the file is **kept at its current value and reported**, never silently defaulted
to another bench's number.
### `points` — required, may be empty
| Field | Meaning |
|---|---|
| `id` | 1-based, stable within the file; used for run progress and resume |
| `target_mm` | where the axes go — required, with all three of `x`, `y`, `z` |
| `sensor_mm` | where that point lands on the sensor; optional but written |
| `clearance_mm` | the standoff for this point |
| `enabled` | false = designed but deliberately skipped |
| `note` | free text |
Both positions are stored on purpose. The platform target is what the run uses;
the sensor position is what makes the point checkable against the surface.
Deriving one from the other on every load would silently change what the points
mean the moment a sensor is re-fixtured — and a re-fixture is exactly when a
stored transform should be *noticed*, not recomputed from.
### `summary` — derived, not authoritative
Present so a human reading the file sees the point count and travel span without
counting rows, and so a diff is legible. It is **recomputed on load**; a mismatch
is reported as a warning and the recomputed values win. That is how a hand-edited
document announces itself instead of quietly disagreeing with its own contents.
`estimated_duration_s` assumes a constant traverse rate
(`sequence_io.ASSUMED_TRAVERSE_MM_S`) and ignores acceleration. It is a planning
figure and is labelled an estimate wherever it is shown.
### `device` — a reference, not a requirement
Records which sensor the sequence was designed for. Importing a sequence for
another sensor is **allowed and warned about**, because re-fixturing
deliberately is a normal thing to do.
## Import rules
Every rejection names the specific problem — `invalid file` tells an operator
nothing.
| Condition | Behaviour |
|---|---|
| not JSON | refused, with line and column |
| top level not an object | refused |
| `kind` missing or different | refused, naming what was found |
| `format_version` missing or not a string | refused |
| `format_version` newer than this build | refused, naming both versions |
| `frame` missing, or `length_unit` not `mm` | refused |
| `profile` not an object | refused |
| a profile key absent | imported; the current value is kept and the key is reported |
| `points` absent | imported as profile-only, and reported |
| a point without `target_mm.x/y/z` | refused, naming the point and the axis |
| `summary` disagreeing with `points` | imported; recomputed, and reported |
| current design is unsaved | the operator is asked before it is replaced |
Version strings are compared as tuples, never as strings: `"1.10"` is newer than
`"1.9"`, and a string comparison would make a newer file look older and load it
as if it were supported.
## Compatibility rules
- **Adding an optional key** does not change `format_version`.
- **Adding a required key, or changing a field's meaning or unit**, does.
- A reader must refuse a document whose version it does not support rather than
interpreting the parts it recognises — a sequence half-applied is worse than
one rejected.
- `1.x` readers are expected to accept `1.y` for `y <= x` on the same major
version and report anything they did not use.
## Where the pieces live
| Concern | Module |
|---|---|
| The document, validation, the derived summary | `apps/autocal/src/orisys_autocal/sequence_io.py` |
| The design screen and its controls | `apps/autocal/src/orisys_autocal/ui/sequence_panel.py` |
| The file dialogs and the unsaved-work guard | `apps/autocal/src/orisys_autocal/ui/window.py` |
| Why a sequence is an artifact (P8) | `apps/autocal/docs/workflow.md` |
@@ -0,0 +1,476 @@
"""Import and export a calibration sequence (workflow P5/P8).
A sequence is a small, human-readable document — a profile plus the points it
generated — so it can be reviewed, diffed, emailed, and reproduced on another
bench. The format follows this repository's other documents rather than
inventing a convention: a dotted ``format_version`` string, sorted sections,
``indent=2 / ensure_ascii=False`` JSON, and an atomic temp-then-replace write
(cf. ``record/experiment.py``'s ``session.json`` and ``force_calibration.json``).
The full field-by-field spec is ``apps/autocal/docs/sequence-format.md``.
Two properties matter more than the field names:
* **``summary`` is derived.** It exists so a human reading the file sees the
point count and the travel span without counting rows. It is *recomputed* on
load, and a mismatch against the file is reported — that is how a hand-edited
document announces itself instead of quietly disagreeing with its own contents.
* **The file states its frame.** A sequence whose numbers do not say whether they
are millimetres or user units is a sequence that will eventually be read wrong,
so ``frame`` records both the unit and the pulse equivalent it was built with.
Nothing here imports Qt, so the format can be exercised from a test process and
a future batch tool.
"""
from __future__ import annotations
import json
import math
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable, Mapping
#: The version this build writes, and the highest it will read.
FORMAT_VERSION = "1.0"
#: What a sequence document says it is. Checked on import: a force-calibration
#: file and a sequence file are both JSON, and applying one as the other must
#: fail loudly rather than half-work.
DOCUMENT_KIND = "autocal.sequence"
#: File extension by convention. Import accepts any ``.json``.
FILE_SUFFIX = ".oryseq"
#: The frame targets are expressed in.
TARGET_FRAME = "platform"
LENGTH_UNIT = "mm"
#: Platform traverse rate assumed when estimating a run's duration, in mm/s.
#: A first-pass planning figure, not a measurement: the axes' own speed setting
#: is in user units and the real rate depends on acceleration and the path.
#: Clearly labelled as an estimate wherever it is shown.
ASSUMED_TRAVERSE_MM_S = 20.0
#: The profile keys a document must carry, matching the panel's fields.
PROFILE_KEYS: tuple[str, ...] = (
"mode",
"pitch_mm",
"margin_mm",
"radius_mm",
"clear_height_mm",
"approach_speed_mm_s",
"press_speed_mm_s",
"press_depth_mm",
"dwell_s",
"settle_window_s",
"repeats",
"order",
)
class SequenceFormatError(ValueError):
"""A sequence document could not be read.
The message is what the operator sees, so it always names the specific
problem (which key, which version, which point) rather than "invalid file".
"""
@dataclass
class SequencePoint:
"""One generated calibration point.
``target_mm`` is where the axes go; ``sensor_mm`` is where that lands on the
sensor. Both are stored: the run needs the first, and the second is what makes
the point checkable against the surface. Re-deriving one from the other on
every load would silently change what the points mean when a sensor is
re-fixtured.
"""
id: int
target_mm: dict[str, float]
sensor_mm: dict[str, float] | None = None
clearance_mm: float = 0.0
enabled: bool = True
note: str = ""
def as_dict(self) -> dict[str, Any]:
payload: dict[str, Any] = {
"id": int(self.id),
"target_mm": {axis: float(self.target_mm.get(axis, 0.0)) for axis in "xyz"},
"clearance_mm": float(self.clearance_mm),
"enabled": bool(self.enabled),
"note": str(self.note),
}
if self.sensor_mm is not None:
payload["sensor_mm"] = {axis: float(self.sensor_mm.get(axis, 0.0)) for axis in "xyz"}
return payload
@dataclass
class SequenceDocument:
"""A sequence as it travels between machines."""
profile: dict[str, Any] = field(default_factory=dict)
points: list[SequencePoint] = field(default_factory=list)
name: str = ""
note: str = ""
device: dict[str, Any] = field(default_factory=dict)
units_per_mm: float = 0.0
created_at: str = ""
summary: dict[str, Any] = field(default_factory=dict)
#: Problems found while reading that did not stop the import. Shown to the
#: operator: "it loaded" and "it loaded as written" are different claims.
warnings: list[str] = field(default_factory=list)
@property
def point_count(self) -> int:
return len(self.points)
@property
def enabled_count(self) -> int:
return sum(1 for point in self.points if point.enabled)
# --------------------------------------------------------------------- derive
def summarise(
points: Iterable[SequencePoint], profile: Mapping[str, Any]
) -> dict[str, Any]:
"""The derived block: count, travel span, and an estimated duration.
Recomputed everywhere rather than trusted from a file, so a document cannot
claim a range its points do not have.
"""
ordered = list(points)
span: dict[str, float] = {}
minimum: dict[str, float] = {}
maximum: dict[str, float] = {}
for axis in "xyz":
values = [float(point.target_mm.get(axis, 0.0)) for point in ordered]
if values:
minimum[axis] = min(values)
maximum[axis] = max(values)
span[axis] = maximum[axis] - minimum[axis]
return {
"count": len(ordered),
"enabled": sum(1 for point in ordered if point.enabled),
"min_mm": minimum,
"max_mm": maximum,
"span_mm": span,
"pitch_mm": _number(profile.get("pitch_mm"), 0.0),
"estimated_duration_s": estimate_duration(ordered, profile),
}
def estimate_duration(
points: Iterable[SequencePoint], profile: Mapping[str, Any]
) -> float:
"""A planning estimate for one full run, in seconds.
Per point: the descent and press, the dwell, and the settle window, repeated
``repeats`` times; plus a traverse between points at
:data:`ASSUMED_TRAVERSE_MM_S`. An estimate, labelled as one wherever it is
shown — it assumes a constant traverse rate and ignores acceleration.
"""
ordered = list(points)
if not ordered:
return 0.0
repeats = max(1, int(_number(profile.get("repeats"), 1)))
clear_height = max(0.0, _number(profile.get("clear_height_mm"), 0.0))
approach = max(1e-6, _number(profile.get("approach_speed_mm_s"), 1.0))
press_speed = max(1e-6, _number(profile.get("press_speed_mm_s"), 1.0))
press_depth = max(0.0, _number(profile.get("press_depth_mm"), 0.0))
dwell = max(0.0, _number(profile.get("dwell_s"), 0.0))
settle = max(0.0, _number(profile.get("settle_window_s"), 0.0))
descent = clear_height / approach
press = press_depth / press_speed
per_point = (descent + press + dwell + settle) * repeats
total = per_point * len(ordered)
pitch = max(0.0, _number(profile.get("pitch_mm"), 0.0))
if pitch > 0:
total += (len(ordered) - 1) * (pitch / ASSUMED_TRAVERSE_MM_S) * repeats
return float(total)
def format_span(low: float | None, high: float | None, *, places: int = 3) -> str:
"""``min–max (span)``, or a single number when the travel is nil.
A zero span means every point shares that coordinate; printing
``12.400–12.400 (0.000)`` would be noise in a line whose job is to be read at
a glance.
"""
if low is None or high is None:
return "—"
if abs(high - low) < 10 ** (-places) / 2:
return f"{low:.{places}f}"
return f"{low:.{places}f}–{high:.{places}f} ({high - low:.{places}f})"
def format_duration(seconds: float | None) -> str:
"""``m:ss``, or a dash when no estimate can be made."""
if seconds is None or not math.isfinite(float(seconds)) or seconds <= 0:
return "—"
total = int(round(float(seconds)))
minutes, rest = divmod(total, 60)
if minutes >= 60:
hours, minutes = divmod(minutes, 60)
return f"{hours:d}:{minutes:02d}:{rest:02d}"
return f"{minutes:d}:{rest:02d}"
# ----------------------------------------------------------------- serialize
def to_document(
*,
profile: Mapping[str, Any],
points: Iterable[SequencePoint] = (),
name: str = "",
note: str = "",
device: Mapping[str, Any] | None = None,
units_per_mm: float = 0.0,
app_version: str = "",
shared_version: str = "",
shared_api: str = "",
) -> dict[str, Any]:
"""Build the sequence document."""
ordered = list(points)
return {
"format_version": FORMAT_VERSION,
"kind": DOCUMENT_KIND,
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"name": str(name),
"note": str(note),
"versions": {
"app": str(app_version),
"shared": str(shared_version),
"shared_api": str(shared_api),
},
"device": dict(device or {}),
"frame": {
"targets": TARGET_FRAME,
"length_unit": LENGTH_UNIT,
"units_per_mm": float(units_per_mm),
},
"profile": {
key: profile.get(key)
for key in PROFILE_KEYS
},
"points": [point.as_dict() for point in ordered],
"summary": summarise(ordered, profile),
}
def save(document: Mapping[str, Any], path: str | Path) -> Path:
"""Write ``document`` atomically, as UTF-8 JSON.
Temp-then-replace: a half-written sequence file that still parses is worse
than no file, because it looks like a design.
"""
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix(target.suffix + ".tmp")
temporary.write_text(
json.dumps(document, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
)
temporary.replace(target)
return target
# ------------------------------------------------------------------- parse
def load(path: str | Path, *, current_profile: Mapping[str, Any] | None = None) -> SequenceDocument:
"""Read a sequence document, validating it.
Args:
path: the file to read.
current_profile: the profile in force now. Missing profile keys are
taken from it and reported, rather than silently defaulting to
another bench's numbers.
Raises:
SequenceFormatError: with a message naming the specific problem.
"""
target = Path(path)
try:
text = target.read_text(encoding="utf-8")
except OSError as exc:
raise SequenceFormatError(f"cannot read {target.name}: {exc}") from exc
try:
data = json.loads(text)
except json.JSONDecodeError as exc:
raise SequenceFormatError(
f"{target.name} is not valid JSON: line {exc.lineno}, column {exc.colno}"
) from exc
if not isinstance(data, dict):
raise SequenceFormatError(
f"{target.name}: the top level must be an object, found {type(data).__name__}"
)
return from_document(data, current_profile=current_profile, source=target.name)
def from_document(
data: Mapping[str, Any],
*,
current_profile: Mapping[str, Any] | None = None,
source: str = "document",
) -> SequenceDocument:
"""Validate and interpret an already-parsed sequence document."""
warnings: list[str] = []
kind = data.get("kind")
if kind != DOCUMENT_KIND:
raise SequenceFormatError(
f"{source}: not a sequence document "
f"(kind is {kind!r}, expected {DOCUMENT_KIND!r})"
)
raw_version = data.get("format_version")
if not isinstance(raw_version, str) or not raw_version.strip():
raise SequenceFormatError(f"{source}: missing or invalid format_version")
if _version_tuple(raw_version) > _version_tuple(FORMAT_VERSION):
raise SequenceFormatError(
f"{source}: written by a newer AutoCal "
f"(format {raw_version}, this build reads up to {FORMAT_VERSION})"
)
frame = data.get("frame")
if not isinstance(frame, dict):
raise SequenceFormatError(f"{source}: missing frame block")
if str(frame.get("length_unit") or "") != LENGTH_UNIT:
raise SequenceFormatError(
f"{source}: length_unit is {frame.get('length_unit')!r}, expected {LENGTH_UNIT!r}"
)
profile_in = data.get("profile")
if not isinstance(profile_in, dict):
raise SequenceFormatError(f"{source}: missing or invalid profile block")
profile: dict[str, Any] = dict(current_profile or {})
missing: list[str] = []
for key in PROFILE_KEYS:
if key in profile_in:
profile[key] = profile_in[key]
else:
missing.append(key)
if missing:
warnings.append(
"profile keys absent, kept the current value: " + ", ".join(sorted(missing))
)
points = _parse_points(data.get("points"), source=source, warnings=warnings)
stored_summary = data.get("summary")
recomputed = summarise(points, profile)
if isinstance(stored_summary, dict) and points:
differences = _summary_differences(stored_summary, recomputed)
if differences:
warnings.append(
"the stored summary disagrees with the points and was recomputed: "
+ "; ".join(differences)
)
device = data.get("device") if isinstance(data.get("device"), dict) else {}
return SequenceDocument(
profile=profile,
points=points,
name=str(data.get("name") or ""),
note=str(data.get("note") or ""),
device=dict(device),
units_per_mm=_number(frame.get("units_per_mm"), 0.0),
created_at=str(data.get("created_at") or ""),
summary=recomputed,
warnings=warnings,
)
def _parse_points(
raw: Any, *, source: str, warnings: list[str]
) -> list[SequencePoint]:
if raw is None:
warnings.append("no points in the file; only the profile was imported")
return []
if not isinstance(raw, list):
raise SequenceFormatError(
f"{source}: points must be a list, found {type(raw).__name__}"
)
points: list[SequencePoint] = []
for index, entry in enumerate(raw, start=1):
where = f"{source}: point {index}"
if not isinstance(entry, dict):
raise SequenceFormatError(f"{where} is not an object")
target = entry.get("target_mm")
if not isinstance(target, dict):
raise SequenceFormatError(f"{where} has no target_mm object")
for axis in "xyz":
if axis not in target:
raise SequenceFormatError(f"{where}: target_mm is missing '{axis}'")
sensor = entry.get("sensor_mm")
points.append(
SequencePoint(
id=int(_number(entry.get("id"), index)),
target_mm={axis: _number(target.get(axis), 0.0) for axis in "xyz"},
sensor_mm=(
{axis: _number(sensor.get(axis), 0.0) for axis in "xyz"}
if isinstance(sensor, dict)
else None
),
clearance_mm=_number(entry.get("clearance_mm"), 0.0),
enabled=bool(entry.get("enabled", True)),
note=str(entry.get("note") or ""),
)
)
return points
def _summary_differences(stored: Mapping[str, Any], recomputed: Mapping[str, Any]) -> list[str]:
"""Where the file's own summary disagrees with its points."""
differences: list[str] = []
if int(_number(stored.get("count"), -1)) != recomputed["count"]:
differences.append(
f"count {stored.get('count')} != {recomputed['count']}"
)
stored_span = stored.get("span_mm")
if isinstance(stored_span, dict):
for axis in "xyz":
if axis in recomputed["span_mm"]:
if not _close(_number(stored_span.get(axis), 0.0), recomputed["span_mm"][axis]):
differences.append(
f"span {axis} {stored_span.get(axis)} != {recomputed['span_mm'][axis]:.3f}"
)
return differences
def _close(first: float, second: float, tolerance: float = 1e-6) -> bool:
return abs(float(first) - float(second)) <= tolerance
def _number(value: Any, fallback: float) -> float:
"""A finite float, or the fallback. Never NaN, never a string."""
try:
number = float(value)
except (TypeError, ValueError):
return float(fallback)
if not math.isfinite(number):
return float(fallback)
return number
def _version_tuple(text: str) -> tuple[int, ...]:
"""``"1.10"`` as ``(1, 10)``.
Compared as tuples rather than strings: ``"1.10" < "1.9"`` is a real trap that
would make a newer file look older and load it as if it were supported.
"""
parts: list[int] = []
for chunk in str(text).split("."):
digits = "".join(character for character in chunk if character.isdigit())
parts.append(int(digits) if digits else 0)
return tuple(parts)
@@ -47,6 +47,12 @@ from PySide6.QtWidgets import (
from orisys_app_shared.i18n import t
from ..preferences import POINT_ORDERS, SURFACE_MODES
from ..sequence_io import (
SequencePoint,
format_duration,
format_span,
summarise,
)
#: Profile fields, in form order: ``(key, locale_key, kind)``.
#: ``kind`` is ``choice`` (a combo box) or ``number`` (a line edit).
@@ -69,6 +75,10 @@ PROFILE_FIELDS: tuple[tuple[str, str, str], ...] = (
PROFILE_ROWS = (len(PROFILE_FIELDS) + 1) // 2
#: Point-table columns: ``(key, locale_key)``.
#:
#: No actions column. It was rendered empty on every row, and an empty action
#: cell reads as a broken control — the per-point actions (re-measure, skip)
#: belong to the run, not to the design screen.
POINT_COLUMNS: tuple[tuple[str, str], ...] = (
("index", "sequence.col_index"),
("x", "sequence.col_x"),
@@ -77,7 +87,6 @@ POINT_COLUMNS: tuple[tuple[str, str], ...] = (
("status", "sequence.col_status"),
("sensitivity", "sequence.col_sensitivity"),
("samples", "sequence.col_samples"),
("actions", "sequence.col_actions"),
)
#: Editability of the point table. Positions are *generated*, not typed: a typed
@@ -85,20 +94,22 @@ POINT_COLUMNS: tuple[tuple[str, str], ...] = (
_EDITABLE_POINT_COLUMNS: tuple[str, ...] = ()
#: Column widths of the point table, by column index.
_POINT_COLUMN_WIDTHS = (50, 130, 130, 130, 120, 160, 90, 200)
_POINT_COLUMN_WIDTHS = (60, 150, 150, 150, 140, 190, 110)
#: Reserved controls and the phase that will fill each one.
PENDING_CONTROLS: tuple[str, ...] = ("generate", "simulate", "dry_run", "save", "load")
PENDING_CONTROLS: tuple[str, ...] = ("generate", "simulate", "dry_run")
#: Which phase each pending control belongs to (see docs/workflow.md).
PENDING_PHASES: dict[str, str] = {
"generate": "P5",
"simulate": "P6",
"dry_run": "P6",
"save": "P8",
"load": "P8",
}
#: Controls that work now: a sequence is a document, and P8 says it is a
#: versioned artifact. Import and export are the first half of that.
LIVE_CONTROLS: tuple[str, ...] = ("export", "import")
#: The validation checks, with the locale key naming what each one proves.
VALIDATION_CHECKS: tuple[tuple[str, str], ...] = (
("on_surface", "sequence.check_surface"),
@@ -140,8 +151,8 @@ class SequencePanel(QGroupBox):
generateRequested = Signal()
simulateRequested = Signal()
dryRunRequested = Signal()
saveRequested = Signal()
loadRequested = Signal()
exportRequested = Signal()
importRequested = Signal()
def __init__(self, profile: dict[str, Any] | None = None, parent: QWidget | None = None) -> None:
super().__init__(parent)
@@ -151,6 +162,8 @@ class SequencePanel(QGroupBox):
self._choices: dict[str, QComboBox] = {}
self._pending_buttons: dict[str, QPushButton] = {}
self._check_rows: dict[str, QLabel] = {}
self._summary: dict[str, Any] = {}
self._dirty = False
outer = QVBoxLayout(self)
outer.setContentsMargins(10, 8, 10, 10)
@@ -169,16 +182,48 @@ class SequencePanel(QGroupBox):
self.set_profile(profile or {})
self.retranslate()
# A profile edit changes the estimate, so the line follows the form.
for widget in self._profile_fields.values():
if isinstance(widget, QComboBox):
widget.currentIndexChanged.connect(self._mark_dirty)
else:
widget.textChanged.connect(self._mark_dirty)
self.refresh_summary()
# ------------------------------------------------------------------ profile
def _build_profile(self) -> QWidget:
box = QGroupBox(self)
box.setObjectName("groupBox")
grid = QGridLayout(box)
grid.setContentsMargins(10, 8, 10, 8)
outer = QVBoxLayout(box)
outer.setContentsMargins(10, 8, 10, 8)
outer.setSpacing(4)
# The name is what the document is called on disk and in the export, so
# it lives at the top of the block that defines the sequence rather than
# in a dialog.
name_row = QHBoxLayout()
name_row.setSpacing(8)
self._name_label = QLabel(box)
self._name_label.setObjectName("hintLabel")
self.name_edit = QLineEdit(box)
self.name_edit.setFixedHeight(28)
name_row.addWidget(self._name_label)
name_row.addWidget(self.name_edit, 1)
outer.addLayout(name_row)
self._note_label = QLabel(box)
self._note_label.setObjectName("hintLabel")
self.note_edit = QLineEdit(box)
self.note_edit.setFixedHeight(28)
outer.addWidget(self._note_label)
outer.addWidget(self.note_edit)
grid = QGridLayout()
grid.setContentsMargins(0, 0, 0, 0)
grid.setHorizontalSpacing(10)
grid.setVerticalSpacing(4)
outer.addLayout(grid)
# Two fields per row: label, field, label, field. Seeing the whole profile
# at once is what lets an operator sanity-check a pitch against a depth.
@@ -258,13 +303,21 @@ class SequencePanel(QGroupBox):
column.addWidget(button)
self._pending_buttons[key] = button
# Import/export work today: a sequence is a portable document (P8).
self._live_buttons: dict[str, QPushButton] = {}
for key in LIVE_CONTROLS:
button = QPushButton(box)
button.setProperty("rail", True)
button.setFixedHeight(28)
column.addWidget(button)
self._live_buttons[key] = button
self._pending_buttons["generate"].clicked.connect(self.generateRequested)
self._pending_buttons["simulate"].clicked.connect(self.simulateRequested)
self._pending_buttons["dry_run"].clicked.connect(self.dryRunRequested)
self._pending_buttons["save"].clicked.connect(self.saveRequested)
self._pending_buttons["load"].clicked.connect(self.loadRequested)
# None of these can run: generation, simulation, the runner and the
# dataset writer belong to later phases (see docs/workflow.md).
self._live_buttons["export"].clicked.connect(self.exportRequested)
self._live_buttons["import"].clicked.connect(self.importRequested)
# Generation, simulation and the runner belong to later phases (P5-P7).
for button in self._pending_buttons.values():
button.setEnabled(False)
@@ -273,6 +326,10 @@ class SequencePanel(QGroupBox):
# -------------------------------------------------------------- point table
def _mark_dirty(self, *_args: Any) -> None:
self._dirty = True
self.refresh_summary()
def _build_point_table(self) -> QWidget:
box = QGroupBox(self)
box.setObjectName("groupBox")
@@ -303,7 +360,6 @@ class SequencePanel(QGroupBox):
4: t("sequence.status_pending"),
5: "—",
6: "—",
7: "",
}
for column_index, text in values.items():
item = QTableWidgetItem(text)
@@ -311,7 +367,15 @@ class SequencePanel(QGroupBox):
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.table.setItem(row, column_index, item)
# The count and span line: the two numbers an operator reasons with, and
# the only place a wrong pitch or margin shows up before a seven-minute
# run does it for them.
self.summary_label = QLabel(box)
self.summary_label.setObjectName("statusValue")
self.summary_label.setWordWrap(True)
column.addWidget(self.table, 1)
column.addWidget(self.summary_label)
self.point_hint = QLabel(box)
self.point_hint.setObjectName("hintLabel")
@@ -400,7 +464,6 @@ class SequencePanel(QGroupBox):
4: status or t("sequence.status_pending"),
5: "—",
6: "—",
7: "",
}
for column, text in values.items():
item = QTableWidgetItem(text)
@@ -409,6 +472,77 @@ class SequencePanel(QGroupBox):
self.table.setItem(row, column, item)
return row
# ----------------------------------------------------------- points & state
def points(self) -> list[SequencePoint]:
"""The list as it stands, for export and for a future runner."""
points: list[SequencePoint] = []
for row in range(self.table.rowCount()):
cells = self.point_row(row)
try:
target = {axis: float(cells[axis]) for axis in "xyz"}
except (TypeError, ValueError):
continue # a placeholder row that was never generated
points.append(
SequencePoint(id=row + 1, target_mm=target, enabled=cells["status"] != t("sequence.status_skipped"))
)
return points
def set_points(self, points: list[SequencePoint]) -> None:
"""Replace the list. Used by import and by the future generator."""
self.table.setRowCount(0)
for point in points:
row = self.table.rowCount()
self.table.insertRow(row)
values = {
0: str(point.id),
1: f"{point.target_mm.get('x', 0.0):.3f}",
2: f"{point.target_mm.get('y', 0.0):.3f}",
3: f"{point.target_mm.get('z', 0.0):.3f}",
4: t("sequence.status_pending") if point.enabled else t("sequence.status_skipped"),
5: "—",
6: "—",
}
for column, text in values.items():
item = QTableWidgetItem(text)
if column >= 4:
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.table.setItem(row, column, item)
self._dirty = True
self.refresh_summary()
@property
def dirty(self) -> bool:
"""Whether the design has changed since it was last saved or loaded.
Import replaces the design outright, and there is no autosave; discarding
a designed sequence without asking would be worse than one extra prompt.
"""
return self._dirty
def mark_clean(self) -> None:
self._dirty = False
def name(self) -> str:
return self.name_edit.text().strip()
def set_name(self, value: str) -> None:
self.name_edit.setText(str(value or ""))
def note(self) -> str:
return self.note_edit.text().strip()
def set_note(self, value: str) -> None:
self.note_edit.setText(str(value or ""))
def refresh_summary(self) -> None:
"""Recompute the count, span and duration line from what is on screen."""
profile = self.profile()
points = self.points()
summary = summarise(points, profile)
self._summary = summary
self.summary_label.setText(_summary_text(summary, profile))
def planned_steps(self) -> list[SequenceStep]:
"""The steps the table describes, for the future runner.
@@ -432,6 +566,10 @@ class SequencePanel(QGroupBox):
for key, combo in self._choices.items():
_fill_choice(combo, key)
self._name_label.setText(t("sequence.field_name"))
self.name_edit.setPlaceholderText(t("sequence.name_hint"))
self._note_label.setText(t("sequence.field_note"))
self.preview_label.setText(t("sequence.preview_placeholder"))
self.preview_hint.setText(t("sequence.preview_hint"))
for key, _locale_key in VALIDATION_CHECKS:
@@ -442,6 +580,9 @@ class SequencePanel(QGroupBox):
self._pending_buttons[key].setToolTip(
t("sequence.pending_tooltip", phase=PENDING_PHASES[key])
)
for key in LIVE_CONTROLS:
self._live_buttons[key].setText(t(f"sequence.btn_{key}"))
self._live_buttons[key].setToolTip(t("sequence.import_export_tip"))
self.table.setHorizontalHeaderLabels([t(locale_key) for _key, locale_key in POINT_COLUMNS])
for row in range(self.table.rowCount()):
@@ -449,6 +590,33 @@ class SequencePanel(QGroupBox):
if item is not None and item.text() in ("", t("sequence.status_pending")):
item.setText(t("sequence.status_pending"))
self.point_hint.setText(t("sequence.points_hint"))
self.refresh_summary()
def _summary_text(summary: dict[str, Any], profile: dict[str, Any]) -> str:
"""The count / span / duration line under the point list.
Spans are ``min–max (span)`` with a nil span printed once; the duration is
labelled an estimate because it is one.
"""
count = int(summary.get("count") or 0)
enabled = int(summary.get("enabled") or count)
if not count:
return t("sequence.summary_empty")
spans = " · ".join(
f"{axis.upper()} {format_span(summary['min_mm'].get(axis), summary['max_mm'].get(axis))}"
for axis in "xyz"
if axis in summary.get("min_mm", {})
)
duration = format_duration(summary.get("estimated_duration_s"))
return t(
"sequence.summary",
count=count,
enabled=enabled,
spans=spans,
duration=duration,
)
def _fill_choice(combo: QComboBox, key: str) -> None:
@@ -314,6 +314,11 @@ class RunBar(QFrame):
self.step_label.setMinimumWidth(140)
layout.addWidget(self.step_label)
# Shown only while idle, in place of the step/progress/timing trio.
self._idle_hint = QLabel(self)
self._idle_hint.setObjectName("hintLabel")
layout.addWidget(self._idle_hint)
self.progress = QProgressBar(self)
self.progress.setTextVisible(False)
self.progress.setFixedHeight(12)
@@ -395,6 +400,14 @@ class RunBar(QFrame):
self.abort_button.setEnabled(self._has_run)
self.pause_button.setEnabled(self._has_run and busy)
self.run_button.setEnabled(not busy)
# With no run, "point 0/0" and "elapsed 0:00" are noise on a strip whose
# job is to be read at a glance. Idle shows the phase and why instead.
live = self._has_run or busy
self.step_label.setVisible(live)
self.progress.setVisible(live)
self.time_label.setVisible(live)
if not live:
self._idle_hint.setText(t("run.no_steps"))
@property
def phase(self) -> str:
@@ -415,6 +428,7 @@ class RunBar(QFrame):
else:
self.step_label.setText(t("run.no_steps"))
self.time_label.setText(t("run.elapsed_only", elapsed=_duration(0.0)))
self._refresh_buttons(self._phase in ("running", "paused"))
def _duration(seconds: float) -> str:
@@ -38,6 +38,14 @@ from PySide6.QtWidgets import (
from .. import __version__ as app_version
from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot
from ..preferences import load_preferences, save_preferences
from ..sequence_io import (
FILE_SUFFIX,
SequenceDocument,
SequenceFormatError,
load,
save,
to_document,
)
from .axis_panel import AxisPanel
from .axis_readout import AxisReadoutPanel
from .brand_bar import BrandBar
@@ -242,6 +250,8 @@ class AutoCalMainWindow(QMainWindow):
self.run_bar.runRequested.connect(self._on_run_requested)
self.run_bar.pauseRequested.connect(self._on_run_pause)
self.run_bar.abortRequested.connect(self._on_run_abort)
self.sequence_panel.exportRequested.connect(self._on_export_sequence)
self.sequence_panel.importRequested.connect(self._on_import_sequence)
self.axis_readout.stopAllRequested.connect(self._on_stop_all)
self.axis_readout.clearAllRequested.connect(self._on_clear_all_alarms)
self.axis_readout.enableAllRequested.connect(self._on_enable_all)
@@ -272,6 +282,106 @@ class AutoCalMainWindow(QMainWindow):
self.statusBar().showMessage(text)
self.status_strip.set_action_result(text, ok)
# ------------------------------------------------------- sequence import/export
def _default_sequence_path(self) -> str:
"""Start the file dialog somewhere useful and named after the profile."""
stem = self.sequence_panel.name() or "sequence"
return str(Path(outputs_dir()) / "sequences" / f"{stem}{FILE_SUFFIX}")
def _sequence_device_ref(self) -> dict:
setup = self.prefs.get("sensor_setup") or {}
return {
"sensor_type": str(setup.get("sensor_type") or ""),
"config_ref": str(setup.get("config_ref") or ""),
"geometry_id": str(setup.get("geometry_id") or ""),
"probe_tip_set": bool(setup.get("probe_tip_set")),
}
def export_sequence(self, path: str) -> str:
"""Write the current design. Returns the path written."""
document = to_document(
profile=self.sequence_panel.profile(),
points=self.sequence_panel.points(),
name=self.sequence_panel.name(),
note=self.sequence_panel.note(),
device=self._sequence_device_ref(),
units_per_mm=float(self.prefs.get("units_per_mm") or 0.0),
app_version=app_version,
shared_version=shared_version,
shared_api=API_VERSION,
)
written = save(document, path)
self.sequence_panel.mark_clean()
return str(written)
def import_sequence(self, path: str) -> SequenceDocument:
"""Read a design and apply it. Raises SequenceFormatError on a bad file."""
document = load(path, current_profile=self.sequence_panel.profile())
self.sequence_panel.set_profile(document.profile)
self.sequence_panel.set_points(document.points)
self.sequence_panel.set_name(document.name)
self.sequence_panel.set_note(document.note)
self.sequence_panel.mark_clean()
return document
def _on_export_sequence(self) -> None:
path, _selected = QFileDialog.getSaveFileName(
self,
t("sequence.dialog_export"),
self._default_sequence_path(),
t("sequence.filter_sequence"),
)
if not path:
return
try:
written = self.export_sequence(path)
except (OSError, ValueError) as exc:
self._report_sequence(t("sequence.export_failed", error=str(exc)), ok=False)
return
self._report_sequence(
t("sequence.exported", name=Path(written).name), ok=True
)
def _on_import_sequence(self) -> None:
# Import replaces the design and there is no autosave, so an unsaved
# sequence is confirmed before it is discarded.
if self.sequence_panel.dirty:
answer = QMessageBox.question(
self,
t("sequence.dialog_import"),
t("sequence.confirm_discard"),
)
if answer != QMessageBox.StandardButton.Yes:
return
path, _selected = QFileDialog.getOpenFileName(
self,
t("sequence.dialog_import"),
str(Path(outputs_dir()) / "sequences"),
t("sequence.filter_sequence"),
)
if not path:
return
try:
document = self.import_sequence(path)
except SequenceFormatError as exc:
self._report_sequence(t("sequence.import_failed", error=str(exc)), ok=False)
return
self._report_sequence(
t("sequence.imported", name=Path(path).name, count=document.point_count), ok=True
)
if document.warnings:
self.statusBar().showMessage(
t("sequence.import_warnings", count=len(document.warnings))
)
for warning in document.warnings:
logger.warning("Sequence import: %s", warning)
def _report_sequence(self, text: str, *, ok: bool) -> None:
self.statusBar().showMessage(text)
self.status_strip.set_action_result(text, ok)
def _apply_chrome_colours(self) -> None:
"""Style the status bar from the theme rather than Qt's default.
+15
View File
@@ -66,6 +66,21 @@ def _scratch_dir() -> Path:
return path
@pytest.fixture
def tmp_dir():
"""A workspace-local scratch directory, cleared per test.
Deliberately not ``tmp_path``: this suite has to run inside a sandboxed
shell, and ``tmp_path`` depends on a writable process temp root that such an
environment may not provide.
"""
import shutil
path = _scratch_dir()
yield path
shutil.rmtree(path, ignore_errors=True)
@pytest.fixture
def make_window(qt_app, monkeypatch):
"""Build a window whose preference writes stay inside a scratch directory.
+47 -16
View File
@@ -1,18 +1,23 @@
"""Binding policy for AutoCal.
"""Binding policy for the Orisys apps.
AutoCal is **PySide6 only**, and this file is the guard for that:
Both shipped apps are **PySide6 only**, and this file is AutoCal's guard for that:
* no module may import PyQt5, PySide2 or PyQt6 — the app's environment has
PySide6 and nothing else, so such an import is a hard start-up failure;
* no module may use an unscoped Qt enumerator (``Qt.AlignLeft``), which PySide6
removed in favour of ``Qt.AlignmentFlag.AlignLeft``;
* ``shared/`` — which serves both this app and the PyQt5-based studio — must
import no Qt binding at all;
* ``shared/`` may import PySide6 and nothing else: it is used by both apps, so it
must not pull in a second binding, and the studio's own guard
(``apps/studio/tests/test_binding_purity.py``) enforces the same rule from its
side. (Until the studio moved to PySide6 this file forbade *any* binding in
``shared/``; that rule existed for a two-binding workspace, which no longer
exists. ``shared/theme/qss.py`` paints its chevron icons with PySide6 now, and
under the old rule those imports were failing silently instead.)
* the controller layer must stay importable from a headless process.
The reason the binding matters at all is licensing, not taste: PyQt5's free
option is GPLv3-or-commercial, while PySide6 is LGPLv3, so an internal
closed-source tool needs no licence purchase. See ``README.md``.
option is GPLv3-or-commercial, while PySide6 is LGPLv3, so a closed-source tool
needs no licence purchase. See ``README.md``.
"""
from __future__ import annotations
@@ -23,13 +28,19 @@ from pathlib import Path
APP_SRC = Path(__file__).resolve().parents[1] / "src"
SHARED_SRC = Path(__file__).resolve().parents[3] / "shared" / "src"
#: Bindings the app must never import.
#: Bindings no module may import.
FORBIDDEN_BINDINGS = {"PyQt5", "PyQt6", "PySide2"}
#: The one binding allowed, and where it may be named.
ALLOWED_BINDING = "PySide6"
#: Scoped enum holders we care about, with the value names actually used.
#:
#: Hand-maintained, and therefore incomplete by construction: the PySide6 migration
#: found ``QPainter.RenderHint.Antialiasing`` and ``QDialogButtonBox.StandardButton.Ok`` slipping through a list
#: like this one. The studio's guard asks PySide6 instead (``getattr`` + enum check),
#: which is the better rule; this map is kept because AutoCal cannot import the binding
#: in a headless test run.
_SCOPED_ENUMS = {
"Qt": (
"AlignmentFlag",
@@ -41,6 +52,12 @@ _SCOPED_ENUMS = {
"ToolButtonStyle",
"FocusPolicy",
"ScrollBarPolicy",
"CursorShape",
"MouseButton",
"Key",
"PenStyle",
"GlobalColor",
"ItemFlag",
),
"QTableWidget": ("SelectionMode", "EditTrigger"),
"QSizePolicy": ("Policy",),
@@ -48,6 +65,11 @@ _SCOPED_ENUMS = {
"QMessageBox": ("StandardButton", "Icon"),
"QDialog": ("DialogCode",),
"QAbstractItemView": ("SelectionMode", "EditTrigger"),
"QDialogButtonBox": ("StandardButton",),
"QPainter": ("RenderHint", "CompositionMode"),
"QImage": ("Format",),
"QSurfaceFormat": ("RenderableType", "OpenGLContextProfile", "SwapBehavior"),
"QFrame": ("Shape",),
}
@@ -72,11 +94,13 @@ def _unscoped_enum_references(path: Path) -> list[str]:
"""Attribute reads like ``Qt.AlignLeft`` — the spelling PySide6 rejects.
Parsed rather than pattern-matched for two reasons: a regex also hits prose
in a comment or docstring (which is exactly where the scoped spelling gets
*explained*), and ``QMessageBox.information(...)`` looks like an enumerator
but is a static method. The parent map separates the two, and
``Qt.AlignmentFlag.AlignLeft`` is an inner attribute on an allow-listed enum
class rather than a plain name, so it is never a candidate.
in a comment or docstring (which is exactly where the *unscoped* spelling gets
explained, so a rewrite would destroy the explanation — this file quotes it
three times, which is why the migration scripts skip it), and
``QMessageBox.information(...)`` looks like an enumerator but is a static
method. The parent map separates the two, and ``Qt.AlignmentFlag.AlignLeft`` is
an inner attribute on an allow-listed enum class rather than a plain name, so it
is never a candidate.
"""
tree = ast.parse(path.read_text(encoding="utf-8"))
called = {
@@ -152,15 +176,22 @@ def test_the_controller_interface_is_qt_free():
assert not offenders, offenders
def test_shared_imports_no_qt_binding():
"""Shared code serves both products, so it must not pick a side."""
def test_shared_imports_no_forbidden_binding():
"""Shared code serves both products, so it must not pull in a second binding.
PySide6 is allowed and expected (``shared/theme/qss.py`` paints the chevron icons with
it); what must never appear here is PyQt5/PyQt6/PySide2, which would either fail to
import in a PySide6-only environment or — worse — work in one app's environment and not
the other's. Before the studio moved to PySide6 this test forbade PySide6 itself, and
the PyQt5 import in ``qss.py`` was failing silently as a result.
"""
offenders = []
for path in _python_files(SHARED_SRC):
qt_imports = _imported_packages(path) & {"PySide2", "PySide6"}
qt_imports = _imported_packages(path) & FORBIDDEN_BINDINGS
if qt_imports:
offenders.append(f"{path.name}: {sorted(qt_imports)}")
assert not offenders, (
"shared code must not import PySide2/PySide6 (studio is PyQt5): "
"shared code may import PySide6 only; these files import another binding: "
f"{offenders}"
)
+124 -7
View File
@@ -17,6 +17,7 @@ import pytest
from conftest import pump
from orisys_autocal.sequence_io import SequenceFormatError, SequencePoint
from orisys_autocal.ui.workspace import TABS
@@ -187,13 +188,7 @@ def test_every_pending_control_names_the_phase_that_owns_it(make_window):
"""A disabled control must say why, or it reads as a broken button."""
window = _show(make_window())
panel = window.sequence_panel
assert panel.pending_controls() == [
"generate",
"simulate",
"dry_run",
"save",
"load",
]
assert panel.pending_controls() == ["generate", "simulate", "dry_run"]
for name, phase in panel.pending_phases().items():
button = panel._pending_buttons[name]
assert not button.isEnabled(), f"{name} is usable but not implemented"
@@ -201,6 +196,24 @@ def test_every_pending_control_names_the_phase_that_owns_it(make_window):
assert button.text(), f"{name} has no label"
def test_import_and_export_are_live_controls(make_window):
"""A sequence is a document, and P8 says it is a versioned artifact."""
window = _show(make_window())
panel = window.sequence_panel
for name in ("export", "import"):
button = panel._live_buttons[name]
assert button.isEnabled(), f"{name} is not usable"
assert button.text(), f"{name} has no label"
assert button.toolTip()
def test_the_point_list_has_no_empty_action_column(make_window):
"""An empty action cell reads as a broken control."""
window = _show(make_window())
panel = window.sequence_panel
assert "actions" not in panel.column_keys()
def test_the_sequence_tab_hands_out_no_runnable_steps(make_window):
"""A runner must not be able to start from points nothing has validated."""
window = _show(make_window())
@@ -456,6 +469,110 @@ def test_the_status_bar_uses_its_own_theme_token(make_window):
assert "QStatusBar" in window.statusBar().styleSheet()
def test_the_sequence_summary_reports_count_and_span(make_window):
"""The two numbers an operator reasons with, before anything runs."""
window = _show(make_window())
panel = window.sequence_panel
panel.set_points(
[
SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 12.4}),
SequencePoint(id=2, target_mm={"x": 10.0, "y": 0.0, "z": 12.4}),
]
)
text = panel.summary_label.text()
assert "2" in text
# A nil Z span prints once, and the real X span prints both ends.
assert "0.000–10.000" in text
assert "12.400" in text
def test_the_sequence_summary_updates_with_the_profile(make_window):
window = _show(make_window())
panel = window.sequence_panel
panel.set_points([SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 1.0})])
before = panel.summary_label.text()
panel.field("repeats").setText("10")
assert panel.summary_label.text() != before
def test_an_empty_sequence_says_so_rather_than_showing_zeros(make_window):
window = _show(make_window())
panel = window.sequence_panel
panel.set_points([])
assert panel.summary_label.text()
assert "0" not in panel.summary_label.text()[:2]
def test_the_design_is_dirty_until_it_is_saved_or_loaded(make_window):
"""Import replaces the design, and there is no autosave."""
window = _show(make_window())
panel = window.sequence_panel
panel.mark_clean()
assert not panel.dirty
panel.field("pitch_mm").setText("9")
assert panel.dirty, "a profile edit did not mark the design dirty"
def test_export_then_import_restores_the_design(make_window, tmp_dir):
"""The round trip through the real panel, not just the format module."""
window = _show(make_window())
panel = window.sequence_panel
panel.set_name("bench sweep")
panel.field("pitch_mm").setText("3.5")
panel.set_points(
[
SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 12.4}),
SequencePoint(id=2, target_mm={"x": 3.5, "y": 0.0, "z": 12.4}),
]
)
path = tmp_dir / "sweep.oryseq"
window.export_sequence(str(path))
assert path.is_file()
assert not panel.dirty, "export did not clear the dirty flag"
# Change everything, then import it back.
panel.set_name("")
panel.field("pitch_mm").setText("1")
panel.set_points([])
document = window.import_sequence(str(path))
assert document.point_count == 2
assert panel.name() == "bench sweep"
assert panel.profile()["pitch_mm"] == 3.5
assert panel.point_row_count() == 2
assert not panel.dirty
def test_importing_a_bad_file_reports_and_changes_nothing(make_window, tmp_dir):
window = _show(make_window())
panel = window.sequence_panel
panel.field("pitch_mm").setText("4")
path = tmp_dir / "bad.oryseq"
path.write_text("not json at all", encoding="utf-8")
with pytest.raises(SequenceFormatError) as excinfo:
window.import_sequence(str(path))
assert "not valid JSON" in str(excinfo.value)
# The design is untouched: a refused import is not a partial import.
assert panel.profile()["pitch_mm"] == 4
def test_an_exported_sequence_names_the_sensor_it_was_designed_for(make_window, tmp_dir):
import json
window = _show(make_window())
window.prefs["sensor_setup"] = {
"sensor_type": "finger",
"geometry_id": "finger-1.0.0",
"probe_tip_set": True,
}
path = tmp_dir / "named.oryseq"
window.export_sequence(str(path))
document = json.loads(path.read_text(encoding="utf-8"))
assert document["device"]["sensor_type"] == "finger"
assert document["device"]["geometry_id"] == "finger-1.0.0"
assert document["versions"]["app"]
def test_language_switch_relabels_the_new_shell(make_window):
from orisys_app_shared.i18n import t
+283
View File
@@ -0,0 +1,283 @@
"""The calibration sequence document: round trip, refusals, derived summary.
The format is a contract with another machine's bench, so most of these tests
are about what happens when the file is *wrong* — a sequence half-applied is
worse than one rejected.
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
import pytest
from orisys_autocal.sequence_io import (
DOCUMENT_KIND,
FORMAT_VERSION,
SequenceFormatError,
SequencePoint,
_version_tuple,
format_duration,
format_span,
from_document,
load,
save,
summarise,
to_document,
)
#: Where scratch files go. Deliberately not ``tmp_dir``: this suite has to run
#: in a sandboxed shell, and ``tmp_dir`` needs a writable process temp root that
#: such an environment may not provide (the same reason ``conftest.make_window``
#: avoids it).
_SCRATCH = Path(__file__).resolve().parents[1] / "outputs" / "_test_sequence_io"
@pytest.fixture
def tmp_dir():
shutil.rmtree(_SCRATCH, ignore_errors=True)
_SCRATCH.mkdir(parents=True, exist_ok=True)
yield _SCRATCH
shutil.rmtree(_SCRATCH, ignore_errors=True)
PROFILE = {
"mode": "flat",
"pitch_mm": 2.0,
"margin_mm": 1.0,
"radius_mm": 0.0,
"clear_height_mm": 5.0,
"approach_speed_mm_s": 2.0,
"press_speed_mm_s": 0.5,
"press_depth_mm": 0.2,
"dwell_s": 0.5,
"settle_window_s": 0.5,
"repeats": 3,
"order": "serpentine",
}
def _points(count: int = 6) -> list[SequencePoint]:
return [
SequencePoint(
id=index + 1,
target_mm={"x": 2.0 * index, "y": 0.0, "z": 12.4},
sensor_mm={"x": 2.0 * index, "y": 0.0, "z": 0.0},
clearance_mm=5.0,
)
for index in range(count)
]
def _document(**overrides) -> dict:
payload = to_document(
profile=PROFILE,
points=_points(),
name="sweep",
units_per_mm=10.0,
app_version="0.1.0",
shared_version="0.1.0",
shared_api="0.1",
)
payload.update(overrides)
return payload
# ------------------------------------------------------------------ round trip
def test_a_document_round_trips_through_a_file(tmp_dir):
path = tmp_dir / "sweep.oryseq"
save(_document(), path)
back = load(path, current_profile=PROFILE)
assert back.point_count == 6
assert back.name == "sweep"
assert back.profile["pitch_mm"] == 2.0
assert back.units_per_mm == 10.0
assert not back.warnings
# Both frames survive: the platform target and the sensor position.
assert back.points[0].target_mm["z"] == pytest.approx(12.4)
assert back.points[0].sensor_mm is not None
def test_the_written_file_is_readable_json_with_the_house_style(tmp_dir):
path = tmp_dir / "sweep.oryseq"
save(_document(), path)
text = path.read_text(encoding="utf-8")
assert text.endswith("\n")
assert "\n " in text, "the document is not indented"
assert "\\u" not in text, "non-ASCII was escaped"
payload = json.loads(text)
assert payload["kind"] == DOCUMENT_KIND
assert payload["format_version"] == FORMAT_VERSION
def test_saving_leaves_no_temporary_file_behind(tmp_dir):
path = tmp_dir / "sweep.oryseq"
save(_document(), path)
assert [entry.name for entry in tmp_dir.iterdir()] == ["sweep.oryseq"]
def test_saving_over_an_existing_file_replaces_it(tmp_dir):
path = tmp_dir / "sweep.oryseq"
save(_document(), path)
save(_document(name="second"), path)
assert json.loads(path.read_text(encoding="utf-8"))["name"] == "second"
# -------------------------------------------------------------------- refusals
@pytest.mark.parametrize(
("case", "mutate", "expected"),
[
("wrong kind", lambda d: d.update(kind="something.else"), "not a sequence document"),
("missing version", lambda d: d.pop("format_version"), "format_version"),
("version not a string", lambda d: d.update(format_version=1.0), "format_version"),
("newer version", lambda d: d.update(format_version="2.0"), "newer AutoCal"),
("missing frame", lambda d: d.pop("frame"), "frame"),
("wrong unit", lambda d: d.update(frame={"length_unit": "um"}), "length_unit"),
("missing profile", lambda d: d.pop("profile"), "profile"),
("points not a list", lambda d: d.update(points={}), "must be a list"),
("point not an object", lambda d: d.update(points=["nope"]), "not an object"),
("point missing axis", lambda d: d.update(points=[{"target_mm": {"x": 1, "y": 2}}]), "missing 'z'"),
("point without target", lambda d: d.update(points=[{}]), "no target_mm"),
],
)
def test_a_bad_document_is_refused_with_a_specific_message(case, mutate, expected):
document = _document()
mutate(document)
with pytest.raises(SequenceFormatError) as excinfo:
from_document(document, current_profile=PROFILE)
assert expected in str(excinfo.value), case
def test_a_non_json_file_is_refused_with_its_position(tmp_dir):
path = tmp_dir / "broken.oryseq"
path.write_text("this is not json", encoding="utf-8")
with pytest.raises(SequenceFormatError) as excinfo:
load(path, current_profile=PROFILE)
assert "not valid JSON" in str(excinfo.value)
assert "line 1" in str(excinfo.value)
def test_a_missing_file_is_refused_not_crashed(tmp_dir):
with pytest.raises(SequenceFormatError):
load(tmp_dir / "absent.oryseq", current_profile=PROFILE)
def test_a_json_array_is_refused(tmp_dir):
path = tmp_dir / "array.oryseq"
path.write_text("[1, 2, 3]", encoding="utf-8")
with pytest.raises(SequenceFormatError) as excinfo:
load(path, current_profile=PROFILE)
assert "top level" in str(excinfo.value)
# ------------------------------------------------------------------- summary
def test_the_summary_reports_count_and_travel_span():
summary = summarise(_points(6), PROFILE)
assert summary["count"] == 6
assert summary["min_mm"]["x"] == 0.0
assert summary["max_mm"]["x"] == 10.0
assert summary["span_mm"]["x"] == pytest.approx(10.0)
# A constant coordinate has a nil span, and says so.
assert summary["span_mm"]["z"] == pytest.approx(0.0)
def test_the_summary_is_recomputed_and_wins_over_the_file():
"""A hand-edited summary must not be believed."""
document = _document()
document["summary"]["count"] = 999
document["summary"]["span_mm"]["x"] = 999.0
back = from_document(document, current_profile=PROFILE)
assert back.summary["count"] == 6
assert back.summary["span_mm"]["x"] == pytest.approx(10.0)
assert back.warnings and "disagrees" in back.warnings[0]
def test_an_agreeing_summary_produces_no_warning():
back = from_document(_document(), current_profile=PROFILE)
assert not any("disagrees" in warning for warning in back.warnings)
def test_no_points_imports_the_profile_and_says_so():
document = _document()
document.pop("points")
back = from_document(document, current_profile=PROFILE)
assert back.point_count == 0
assert any("no points" in warning for warning in back.warnings)
def test_a_missing_profile_key_keeps_the_current_value():
document = _document()
document["profile"].pop("pitch_mm")
back = from_document(document, current_profile=PROFILE)
assert back.profile["pitch_mm"] == 2.0
assert any("pitch_mm" in warning for warning in back.warnings)
def test_a_disabled_point_is_counted_separately():
points = _points(4)
points[1] = SequencePoint(id=2, target_mm=points[1].target_mm, enabled=False)
summary = summarise(points, PROFILE)
assert summary["count"] == 4
assert summary["enabled"] == 3
# ------------------------------------------------------------------- duration
def test_the_duration_estimate_scales_with_repeats_and_points():
one = summarise(_points(1), {**PROFILE, "repeats": 1})["estimated_duration_s"]
three = summarise(_points(1), {**PROFILE, "repeats": 3})["estimated_duration_s"]
assert three > one
six = summarise(_points(6), {**PROFILE, "repeats": 3})["estimated_duration_s"]
assert six > three
def test_no_points_means_no_estimate():
summary = summarise([], PROFILE)
assert summary["estimated_duration_s"] == 0.0
assert format_duration(0.0) == "—"
def test_the_duration_is_formatted_as_a_bench_timer():
assert format_duration(72.0) == "1:12"
assert format_duration(3600.0) == "1:00:00"
assert format_duration(None) == "—"
assert format_duration(float("nan")) == "—"
# ---------------------------------------------------------------------- spans
def test_a_zero_span_prints_once_not_twice():
assert format_span(12.4, 12.4) == "12.400"
assert format_span(0.0, 0.0) == "0.000"
def test_a_real_span_shows_both_ends_and_the_length():
assert format_span(0.0, 10.0) == "0.000–10.000 (10.000)"
def test_a_missing_span_is_a_dash():
assert format_span(None, None) == "—"
# --------------------------------------------------------------------- version
def test_versions_compare_as_tuples_not_strings():
"""``"1.10" < "1.9"`` as strings would load a newer file as if supported."""
assert _version_tuple("1.10") > _version_tuple("1.9")
assert _version_tuple("1.0") == (1, 0)
assert _version_tuple("1") == (1,)
# A same-major newer minor is still refused, but is *recognised* as newer.
document = _document()
document["format_version"] = "1.10"
with pytest.raises(SequenceFormatError):
from_document(document, current_profile=PROFILE)
+39 -26
View File
@@ -77,8 +77,8 @@ STRINGS = {
"stop_all": "全部停止",
"run_title": "运行",
"run_phase": "空闲",
"run_step": "第 0/0 点",
"run_time": "已用 0:00 · 预计剩余 —",
"run_idle_hint": "尚未生成标定点",
"run_run": "运行",
"run_pause": "暂停",
"run_abort": "中止",
@@ -129,6 +129,9 @@ STRINGS = {
"seq_preview": "预览",
"seq_validation": "校验",
"seq_points": "标定点列表",
"seq_name": "序列名称",
"seq_note": "备注",
"seq_summary": "6 点(6 启用) · X 0.000–10.000 (10.000) · Y 0.000 · Z 12.400–13.400 (1.000) · 预计 2:24(估计值)",
"seq_fields": [
("曲面模式", "平面"), ("点距 (mm)", "2.000"), ("边界余量 (mm)", "1.000"),
("曲面半径 (mm)", "—"), ("安全高度 (mm)", "5.000"), ("进给速度 (mm/s)", "2.000"),
@@ -140,12 +143,12 @@ STRINGS = {
"seq_preview_hint": "生成后可在此检查点是否落在曲面上、是否在行程内、接触能否被确认。",
"seq_checks": ["在曲面范围内", "在行程内", "接触可确认"],
"seq_checks_pending": "尚未生成标定点。",
"seq_buttons": [("生成标定点", True), ("仿真", True), ("干跑(不接触)", True), ("保存序列…", True), ("载入序列…", True)],
"point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "状态", "灵敏度(上次)", "样本", "操作"],
"seq_buttons": [("生成标定点", True), ("仿真", True), ("干跑(不接触)", True), ("导出序列…", False), ("导入序列…", False)],
"point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "状态", "灵敏度(上次)", "样本"],
"point_rows": [
["1", "0.000", "0.000", "12.400", "待测", "—", "—", ""],
["2", "2.000", "0.000", "12.400", "待测", "—", "—", ""],
["3", "4.000", "0.000", "12.400", "待测", "—", "—", ""],
["1", "0.000", "0.000", "12.400", "待测", "—", "—"],
["2", "2.000", "0.000", "12.400", "待测", "—", "—"],
["3", "4.000", "0.000", "12.400", "待测", "—", "—"],
],
"points_hint": "坐标由网格生成并逐点校验,不接受手工输入:手工输入的坐标没有任何检查,一个错数字会毁掉整条序列。",
"sensor_note": "设备与信号源、实时图像、力曲线",
@@ -169,8 +172,8 @@ STRINGS = {
"stop_all": "Stop all",
"run_title": "Run",
"run_phase": "Idle",
"run_step": "Point 0/0",
"run_time": "Elapsed 0:00 · about — left",
"run_idle_hint": "No points generated",
"run_run": "Run",
"run_pause": "Pause",
"run_abort": "Abort",
@@ -221,6 +224,9 @@ STRINGS = {
"seq_preview": "Preview",
"seq_validation": "Checks",
"seq_points": "Calibration points",
"seq_name": "Sequence name",
"seq_note": "Note",
"seq_summary": "6 points (6 enabled) · X 0.000-10.000 (10.000) · Y 0.000 · Z 12.400-13.400 (1.000) · about 2:24 (estimate)",
"seq_fields": [
("Surface mode", "Flat"), ("Pitch (mm)", "2.000"), ("Margin (mm)", "1.000"),
("Radius (mm)", "—"), ("Clear height (mm)", "5.000"), ("Approach (mm/s)", "2.000"),
@@ -232,12 +238,12 @@ STRINGS = {
"seq_preview_hint": "After generation, check each point is on the surface, inside travel and confirmable by contact.",
"seq_checks": ["On the surface", "Within travel", "Contact confirmable"],
"seq_checks_pending": "No points generated yet.",
"seq_buttons": [("Generate points", True), ("Simulate", True), ("Dry run (no contact)", True), ("Save sequence…", True), ("Load sequence…", True)],
"point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "Status", "Sensitivity (last)", "Samples", "Actions"],
"seq_buttons": [("Generate points", True), ("Simulate", True), ("Dry run (no contact)", True), ("Export sequence…", False), ("Import sequence…", False)],
"point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "Status", "Sensitivity (last)", "Samples"],
"point_rows": [
["1", "0.000", "0.000", "12.400", "Pending", "—", "—", ""],
["2", "2.000", "0.000", "12.400", "Pending", "—", "—", ""],
["3", "4.000", "0.000", "12.400", "Pending", "—", "—", ""],
["1", "0.000", "0.000", "12.400", "Pending", "—", "—"],
["2", "2.000", "0.000", "12.400", "Pending", "—", "—"],
["3", "4.000", "0.000", "12.400", "Pending", "—", "—"],
],
"points_hint": "Coordinates are generated and checked per point; they are not typed. A typed coordinate is one nothing has checked.",
"sensor_note": "Device and source, live image, force curves",
@@ -644,13 +650,22 @@ def draw_sequence_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect)
profile = QRect(inner.x(), inner.y(), profile_w, top_h)
p_inner = _panel(painter, profile, palette)
_box_title(painter, profile, s["seq_profile"], palette)
# the name and note the document carries
y = p_inner.y() + 4
_text(painter, QRect(p_inner.x(), y, 70, 22), s["seq_name"], palette, size=9, muted=True)
_field(painter, QRect(p_inner.x() + 72, y, p_inner.width() - 78, 22), "5 点灵敏度扫描", palette)
y += 26
_text(painter, QRect(p_inner.x(), y, 70, 22), s["seq_note"], palette, size=9, muted=True)
_field(painter, QRect(p_inner.x() + 72, y, p_inner.width() - 78, 22), "", palette)
y += 28
column_w = p_inner.width() // 2
for index, (label, value) in enumerate(s["seq_fields"]):
row, side = divmod(index, 2)
x = p_inner.x() + side * column_w
y = p_inner.y() + 20 + row * 26
_text(painter, QRect(x, y, 130, 22), label, palette, size=9, muted=True)
_field(painter, QRect(x + 128, y, column_w - 140, 22), value, palette)
row_y = y + row * 26
_text(painter, QRect(x, row_y, 130, 22), label, palette, size=9, muted=True)
_field(painter, QRect(x + 128, row_y, column_w - 140, 22), value, palette)
# --- preview
preview = QRect(inner.x() + profile_w + gap, inner.y(), preview_w, top_h)
@@ -699,7 +714,9 @@ def draw_sequence_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect)
points = QRect(inner.x(), inner.y() + top_h + gap, inner.width(), inner.height() - top_h - gap)
t_inner = _panel(painter, points, palette)
_box_title(painter, points, s["seq_points"], palette)
widths = (50, 150, 150, 150, 130, 190, 100)
# Seven columns: no empty actions column, and the summary line carries the
# count and span the operator reasons with.
widths = (60, 150, 150, 150, 140, 190, 110)
widths = widths + (t_inner.width() - sum(widths),)
header_h = 28
header = QRect(t_inner.x(), t_inner.y() + 4, t_inner.width(), header_h)
@@ -719,7 +736,9 @@ def draw_sequence_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect)
for value, width in zip(row, widths):
_text(painter, QRect(x, row_y, width, row_h), value, palette, size=10, align=Qt.AlignmentFlag.AlignCenter)
x += width
y = header.bottom() + 1 + len(s["point_rows"]) * row_h + 10
y = header.bottom() + 1 + len(s["point_rows"]) * row_h + 8
_text(painter, QRect(t_inner.x(), y, t_inner.width(), 22), s["seq_summary"], palette, size=10)
y += 24
_text(painter, QRect(t_inner.x(), y, t_inner.width(), 34), s["points_hint"], palette, size=9, muted=True)
@@ -917,13 +936,7 @@ def draw_run_bar(painter: QPainter, palette: Palette, s: dict, top: int) -> None
x += painter.fontMetrics().horizontalAdvance(s["run_title"]) + 20
_text(painter, QRect(x, top, 90, RUNBAR_H), s["run_phase"], palette, size=10, muted=True)
x += 96
_text(painter, QRect(x, top, 150, RUNBAR_H), s["run_step"], palette, size=10)
x += 158
bar = QRect(x, top + 14, 300, 12)
painter.fillRect(bar, palette.border)
painter.fillRect(QRect(bar.x(), bar.y(), 3 * bar.width() // 4, bar.height()), palette.accent)
_text(painter, QRect(bar.right() + 12, top, 260, RUNBAR_H), s["run_time"], palette, size=10, muted=True)
_text(painter, QRect(x, top, 420, RUNBAR_H), s["run_idle_hint"], palette, size=9, muted=True)
_button(painter, QRect(PAGE_W - 330, top + 7, 96, 26), s["run_run"], palette, disabled=True)
_button(painter, QRect(PAGE_W - 228, top + 7, 96, 26), s["run_pause"], palette, disabled=True)
+4 -4
View File
@@ -8,8 +8,8 @@ inside parents, controls gated by connection and bus state, locales complete.
python apps/autocal/tools/panel_check.py
No controller, no DLL and no display are needed. Mirrors
``apps/studio/tools/ui_check.py``, but cannot import it: that module imports
PyQt5, and AutoCal's environment has none.
``apps/studio/tools/ui_check.py``, but cannot import it: that module pulls in
``orisys_studio`` (the acquisition stack), which AutoCal's environment has none of.
"""
from __future__ import annotations
@@ -218,8 +218,8 @@ def main() -> int:
"the sequence point list is empty",
)
checker.that(
sequence.table.columnCount() == 8,
f"the point list has {sequence.table.columnCount()} columns, expected 8",
sequence.table.columnCount() == len(sequence.column_keys()),
f"the point list has {sequence.table.columnCount()} columns, expected {len(sequence.column_keys())}",
)
# The shell: one rail, a run bar, and a health chip.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

+4 -1
View File
@@ -9,7 +9,10 @@ description = "Orisys Studio desktop app"
requires-python = ">=3.10"
authors = [{ name = "Orisys-Tech" }]
dependencies = [
"PyQt5>=5.15",
# PySide6 is the LGPLv3 Qt binding: the licence, not the API, is why the studio is not
# on PyQt5 (whose free option is GPLv3-or-commercial). See
# docs/architecture/studio-3d-design.md §11.1 and apps/studio/tests/test_binding_purity.py.
"PySide6>=6.6",
"orisys-app-shared>=0.1.0",
"orisys>=0.5",
"scipy>=1.10.1",
+34 -19
View File
@@ -9,11 +9,9 @@ from pathlib import Path
from typing import Any
import numpy as np
from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtGui import QIcon, QImage
from PyQt5.QtWidgets import (
QAction,
QActionGroup,
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QAction, QActionGroup, QIcon, QImage
from PySide6.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
@@ -240,7 +238,7 @@ class StudioMainWindow(QMainWindow):
root.addWidget(self.toolbar)
self.status_chip = self.toolbar.status_chip
self.content_splitter = QSplitter(Qt.Horizontal)
self.content_splitter = QSplitter(Qt.Orientation.Horizontal)
self.mesh3d_panel = Mesh3dPanel(theme=self._theme)
self.arrow_panel = ArrowPanel(theme=self._theme)
self.force_plots = ForcePlotsPanel(theme=self._theme)
@@ -888,7 +886,7 @@ class StudioMainWindow(QMainWindow):
# convention everyone's hands already know.
self.act_play = QAction(self)
self.act_play.setShortcut("Space")
self.act_play.setShortcutContext(Qt.ApplicationShortcut)
self.act_play.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)
self.addAction(self.act_play)
self.view_menu = menu_bar.addMenu("")
@@ -1402,7 +1400,7 @@ class StudioMainWindow(QMainWindow):
self._persist_prefs()
def _show_about(self) -> None:
AboutDialog(self).exec_()
AboutDialog(self).exec()
def _on_camera_id_changed(self, value: int) -> None:
self.prefs["camera_id"] = str(int(value))
@@ -1507,7 +1505,7 @@ class StudioMainWindow(QMainWindow):
filters_locked=bool(self._running or self.recorder.is_recording),
sample_rate_hz=self.display_ctl.device_sample_rate(),
)
if dialog.exec_() != PlotSettingsDialog.Accepted:
if dialog.exec() != PlotSettingsDialog.Accepted:
return
window_sec, fn_max, shear_max, fn_raw_max, shear_raw_max = dialog.values()
self.display_ctl.apply_plot_settings(
@@ -1589,10 +1587,10 @@ class StudioMainWindow(QMainWindow):
sensor_type=sensor_type,
config=config_ref,
),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if answer != QMessageBox.Yes:
if answer != QMessageBox.StandardButton.Yes:
self.recorder.log_event(
"calibration_apply_blocked", "; ".join(reasons)
)
@@ -1602,10 +1600,10 @@ class StudioMainWindow(QMainWindow):
self,
t("cal.apply_title"),
t("cal.apply_confirm", sensor_type=sensor_type, config=config_ref),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if answer != QMessageBox.Yes:
if answer != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage(t("cal.apply_skipped"))
return
worker = self._worker_for(sensor_type)
@@ -1720,7 +1718,18 @@ class StudioMainWindow(QMainWindow):
if getattr(self, "act_run", None) is None:
return
self.act_run.setEnabled(self.toolbar.btn_run.isEnabled())
self.act_stop.setEnabled(self._session_active)
# The menu's Stop mirrors the toolbar's stop affordance, not the acquisition
# session alone: a replay or a recompute pass is something to stop.
#
# This is not cosmetic. Qt6 honours ``enabled`` in ``QAction::trigger()`` (Qt5
# did not) and never delivers the shortcut of a disabled action, so a Stop that
# is disabled while a replay is playing is a keyboard escape hatch that does not
# work — the one thing ``_on_stop_shortcut`` exists to guarantee.
self.act_stop.setEnabled(
self._session_active
or self.replay_ctl.is_playing()
or self._recompute_running
)
self.act_preview.setEnabled(self.toolbar.btn_preview.isEnabled())
self.act_reset.setEnabled(self.toolbar.btn_reset.isEnabled())
@@ -1740,10 +1749,16 @@ class StudioMainWindow(QMainWindow):
In the replay view it is the same escape hatch as the rail's 停止 button: a
replay or a recompute pass that will not stop by itself has to be stoppable
from the keyboard.
from the keyboard. The replay branch accepts the same three states
``_sync_shortcut_actions`` enables the action for, so the action can never be
enabled with nothing behind it.
"""
if self.toolbar.run_role() == "playback":
if self.replay_ctl.is_playing() or self._session_active:
if (
self.replay_ctl.is_playing()
or self._session_active
or self._recompute_running
):
self.replay_ctl.stop_activity()
return
if not self._session_active:
@@ -2348,7 +2363,7 @@ class StudioMainWindow(QMainWindow):
geo = self.prefs.get("window_geometry")
if isinstance(geo, str) and geo:
try:
from PyQt5.QtCore import QByteArray
from PySide6.QtCore import QByteArray
self.restoreGeometry(QByteArray.fromHex(geo.encode("ascii")))
except Exception:
@@ -21,7 +21,7 @@ from __future__ import annotations
import logging
from typing import Any, Optional, Protocol
from PyQt5.QtCore import QObject
from PySide6.QtCore import QObject
logger = logging.getLogger(__name__)
+7 -2
View File
@@ -6,7 +6,12 @@ import logging
import os
import sys
from PyQt5.QtWidgets import QApplication
# pyqtgraph picks its toolkit from whichever binding is already imported, and in a
# development environment that also has PyQt5 installed (``orisys[gui]``) it can pick the
# wrong one. The application is PySide6-only, so say so before anything imports pyqtgraph.
os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6")
from PySide6.QtWidgets import QApplication # noqa: E402
from orisys_app_shared import API_VERSION, __version__ as shared_version
from orisys_app_shared.compat import check_shared_compat
@@ -85,7 +90,7 @@ def main() -> int:
language,
gl_profile,
)
code = int(app.exec_())
code = int(app.exec())
# Leave the process immediately.
#
+5 -5
View File
@@ -24,7 +24,7 @@ from pathlib import Path
from typing import Optional
import numpy as np
from PyQt5.QtCore import QObject, pyqtSignal
from PySide6.QtCore import QObject, Signal
from orisys_app_shared.i18n import t
from orisys_app_shared.record import RecordingReader, VideoOnlySource
@@ -47,13 +47,13 @@ class ReplayEngine(QObject):
"""Open one recording and answer "what is at t?" for every view."""
#: The cursor moved; the chart cursor and the 2D panel follow.
position_changed = pyqtSignal(float)
position_changed = Signal(float)
#: The frame under the cursor changed.
frames_changed = pyqtSignal()
frames_changed = Signal()
#: The notes about this recording, or the loaded recording, changed.
status_changed = pyqtSignal()
status_changed = Signal()
#: A recording was opened (True) or the previous one was dropped (False).
recording_changed = pyqtSignal(bool)
recording_changed = Signal(bool)
def __init__(self, parent=None) -> None:
super().__init__(parent)
@@ -28,8 +28,8 @@ from pathlib import Path
from typing import Any, Optional, Protocol
import numpy as np
from PyQt5.QtCore import QObject, QTimer
from PyQt5.QtWidgets import QFileDialog
from PySide6.QtCore import QObject, QTimer
from PySide6.QtWidgets import QFileDialog
from orisys_app_shared.acquisition.image_backend import SOURCE_REPLAY, ImageFrame
from orisys_app_shared.i18n import t
@@ -33,7 +33,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Optional
from PyQt5.QtCore import QObject, pyqtSignal
from PySide6.QtCore import QObject, Signal
from orisys_app_shared.paths import config_dir
from orisys_app_shared.scene3d.contracts import (
@@ -180,8 +180,8 @@ class SceneAssetLoader(QObject):
selection the operator has already moved away from.
"""
loaded = pyqtSignal(object, str, int, object) # SceneAssets, description, id, device
failed = pyqtSignal(object, int) # Diagnostic, request id
loaded = Signal(object, str, int, object) # SceneAssets, description, id, device
failed = Signal(object, int) # Diagnostic, request id
def __init__(self, parent: Optional[QObject] = None) -> None:
super().__init__(parent)
@@ -72,21 +72,21 @@ def configure_surface_format(profile: Profile = "desktop33") -> None:
component must not slow down (the acquisition loop feeds the scene through a
capacity-one mailbox), and the design document defers anti-aliasing work.
"""
from PyQt5.QtGui import QSurfaceFormat
from PySide6.QtGui import QSurfaceFormat
fmt = QSurfaceFormat()
if profile == "gles30":
fmt.setRenderableType(QSurfaceFormat.OpenGLES)
fmt.setRenderableType(QSurfaceFormat.RenderableType.OpenGLES)
fmt.setVersion(3, 0)
elif profile == "desktop33":
fmt.setRenderableType(QSurfaceFormat.OpenGL)
fmt.setRenderableType(QSurfaceFormat.RenderableType.OpenGL)
fmt.setVersion(3, 3)
fmt.setProfile(QSurfaceFormat.CoreProfile)
fmt.setProfile(QSurfaceFormat.OpenGLContextProfile.CoreProfile)
else: # pragma: no cover - a typo must not reach a board silently
raise ValueError(f"unknown GL profile: {profile!r}")
fmt.setDepthBufferSize(24)
fmt.setStencilBufferSize(8)
fmt.setSwapBehavior(QSurfaceFormat.DoubleBuffer)
fmt.setSwapBehavior(QSurfaceFormat.SwapBehavior.DoubleBuffer)
fmt.setSamples(0)
QSurfaceFormat.setDefaultFormat(fmt)
@@ -442,7 +442,10 @@ class GLDispatch:
if context is None or not context.isValid():
raise error("gl_unsupported", "no current OpenGL context")
fmt = context.format()
profile = profile_from_renderable_type(int(fmt.renderableType()))
# PySide6 enums are not ints: `int(QSurfaceFormat.OpenGLES)` raises TypeError, so read
# `.value`. (PyQt5 exposed them as ints. Getting this wrong made every GL probe raise,
# the 3D pane report "unsupported", and the GL smoke tests skip instead of running.)
profile = profile_from_renderable_type(int(fmt.renderableType().value))
try:
vendor = _gl_string(GL, GL.GL_VENDOR)
renderer = _gl_string(GL, GL.GL_RENDERER)
@@ -454,7 +457,7 @@ class GLDispatch:
"gl_unsupported",
f"the context answered no queries: {type(exc).__name__}: {exc}",
) from exc
from PyQt5.QtGui import QGuiApplication
from PySide6.QtGui import QGuiApplication
platform_name = ""
try:
+13 -13
View File
@@ -22,9 +22,9 @@ from typing import TYPE_CHECKING, Optional
import numpy as np
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtGui import QImage
from PyQt5.QtWidgets import (
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QImage
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDoubleSpinBox,
@@ -87,7 +87,7 @@ class ColorScaleBar(QLabel):
self.setObjectName("colorScaleBar")
self.setFixedHeight(12)
self.setMinimumWidth(120)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.setVisible(False)
self._lut_id = "sequential"
@@ -99,14 +99,14 @@ class ColorScaleBar(QLabel):
self.update()
def paintEvent(self, event) -> None: # noqa: N802 - Qt naming
from PyQt5.QtGui import QPainter
from PySide6.QtGui import QPainter
from orisys_studio.scene3d.gl_backend import lut_rgba
# (256, 1, 4) RGBA -> one scanline; the bytes object stays alive for the call,
# which is what QImage's borrowed buffer needs.
rgba = np.ascontiguousarray(lut_rgba(self._lut_id)[:, 0, :]).tobytes()
image = QImage(rgba, 256, 1, 256 * 4, QImage.Format_RGBA8888)
image = QImage(rgba, 256, 1, 256 * 4, QImage.Format.Format_RGBA8888)
painter = QPainter(self)
painter.drawImage(self.rect(), image)
painter.end()
@@ -115,10 +115,10 @@ class ColorScaleBar(QLabel):
class ThreeDPanel(QFrame):
"""Toggle, presets, reset, housing visibility, gain, colour scale, status."""
enabledChanged = pyqtSignal(bool)
settingsChanged = pyqtSignal(object) # DisplaySettings
resetRequested = pyqtSignal()
presetRequested = pyqtSignal(str)
enabledChanged = Signal(bool)
settingsChanged = Signal(object) # DisplaySettings
resetRequested = Signal()
presetRequested = Signal(str)
def __init__(
self,
@@ -299,13 +299,13 @@ class ThreeDPanel(QFrame):
scale_row.setSpacing(6)
self.scale_lower_label = QLabel()
self.scale_lower_label.setObjectName("panelMeta")
self.scale_lower_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.scale_lower_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
scale_row.addWidget(self.scale_lower_label)
self.scale_bar = ColorScaleBar()
scale_row.addWidget(self.scale_bar, 1)
self.scale_upper_label = QLabel()
self.scale_upper_label.setObjectName("panelMeta")
self.scale_upper_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
self.scale_upper_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
scale_row.addWidget(self.scale_upper_label)
root.addLayout(scale_row)
@@ -327,7 +327,7 @@ class ThreeDPanel(QFrame):
self.source_label = ShrinkableLabel()
self.source_label.setObjectName("panelMeta")
self.source_label.setWordWrap(False)
self.source_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.source_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
legend_row.addWidget(self.source_label, 2)
root.addLayout(legend_row)
@@ -16,8 +16,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QLabel,
@@ -60,7 +60,7 @@ class Scene3dSettingsDialog(QDialog):
root.addWidget(panel.controls_widget)
panel.controls_widget.setVisible(True)
self.buttons = QDialogButtonBox(QDialogButtonBox.Close)
self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
self.buttons.rejected.connect(self.close)
self.buttons.accepted.connect(self.close)
root.addWidget(self.buttons)
@@ -72,7 +72,7 @@ class Scene3dSettingsDialog(QDialog):
self.hint.setText(t("scene3d.settings_hint"))
# Qt ships its own translations for a button box, and the application installs
# none: without this the dialog ends in an English "Close" among Chinese labels.
close_button = self.buttons.button(QDialogButtonBox.Close)
close_button = self.buttons.button(QDialogButtonBox.StandardButton.Close)
if close_button is not None:
close_button.setText(t("dialog.close"))
@@ -84,7 +84,7 @@ class Scene3dSettingsDialog(QDialog):
def keyPressEvent(self, event) -> None: # noqa: N802 - Qt naming
"""Esc closes the dialog, as it does everywhere else in this application."""
if event.key() == Qt.Key_Escape:
if event.key() == Qt.Key.Key_Escape:
self.hide()
return
super().keyPressEvent(event)
@@ -28,8 +28,8 @@ import logging
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Callable, Optional
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtWidgets import QVBoxLayout, QWidget
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QVBoxLayout, QWidget
from orisys_app_shared.scene3d.composer import SceneComposer
from orisys_app_shared.scene3d.contracts import (
@@ -71,9 +71,9 @@ def gl_available() -> tuple[bool, str]:
pane on a machine where PyOpenGL is not installed.
"""
try:
from PyQt5.QtWidgets import QOpenGLWidget # noqa: F401
from PySide6.QtOpenGLWidgets import QOpenGLWidget # noqa: F401
except Exception as exc: # noqa: BLE001
return False, f"PyQt5 OpenGL widgets are unavailable: {exc}"
return False, f"PySide6 OpenGL widgets are unavailable: {exc}"
try:
import OpenGL # noqa: F401
except Exception as exc: # noqa: BLE001
@@ -84,11 +84,11 @@ def gl_available() -> tuple[bool, str]:
class Scene3DView(QWidget):
"""One 3D pane: the GL widget on top, its controls underneath."""
stateChanged = pyqtSignal(object) # SceneState
diagnosticRaised = pyqtSignal(object) # Diagnostic
renderStatsChanged = pyqtSignal(object) # RenderStats
descriptorChanged = pyqtSignal(object) # Optional[FieldDescriptor]
enabledChanged = pyqtSignal(bool)
stateChanged = Signal(object) # SceneState
diagnosticRaised = Signal(object) # Diagnostic
renderStatsChanged = Signal(object) # RenderStats
descriptorChanged = Signal(object) # Optional[FieldDescriptor]
enabledChanged = Signal(bool)
def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
+23 -16
View File
@@ -19,9 +19,11 @@ from __future__ import annotations
import time
from typing import Optional
from PyQt5.QtCore import QEvent, Qt, pyqtSignal
from PyQt5.QtGui import QMouseEvent, QWheelEvent
from PyQt5.QtWidgets import QOpenGLWidget, QWidget
from PySide6.QtCore import QEvent, Qt, Signal
from PySide6.QtGui import QMouseEvent, QWheelEvent
# Qt6 moved QOpenGLWidget out of QtWidgets into its own module; PySide6 follows.
from PySide6.QtOpenGLWidgets import QOpenGLWidget
from PySide6.QtWidgets import QWidget
from orisys_app_shared.scene3d.contracts import (
Diagnostic,
@@ -48,13 +50,13 @@ STATS_INTERVAL_S = 0.5
class Sensor3DWidget(QOpenGLWidget):
"""Renders one scene. Signals carry state, never per-frame arrays."""
capabilitiesChanged = pyqtSignal(object) # GLCapabilities
diagnosticRaised = pyqtSignal(object) # Diagnostic
renderStatsChanged = pyqtSignal(object) # RenderStats, rate-limited
capabilitiesChanged = Signal(object) # GLCapabilities
diagnosticRaised = Signal(object) # Diagnostic
renderStatsChanged = Signal(object) # RenderStats, rate-limited
#: What the frame on screen claims to be — quantity, unit, quality. The panel
#: labels the colour bar from this and nothing else, so an unmeasured surface
#: cannot inherit the previous frame's range.
descriptorChanged = pyqtSignal(object) # Optional[FieldDescriptor]
descriptorChanged = Signal(object) # Optional[FieldDescriptor]
def __init__(
self,
@@ -259,14 +261,19 @@ class Sensor3DWidget(QOpenGLWidget):
When the platform cannot create a GL context (the offscreen plugin, a broken
driver, a board without EGL) ``initializeGL`` is simply never called: there is
no exception to catch, and the widget would sit there looking like a working
view that has no data yet. Qt does answer the question, though — ``context()``
stays ``None`` — so the first paint asks it and turns "no context" into the
state the rest of the application already knows how to show.
view that has no data yet. Qt does answer the question, though, so the first
paint asks it and turns "no context" into the state the rest of the application
already knows how to show.
Under Qt5 that answer was ``context() is None``. Qt6 returns an *invalid*
context object instead, so the check has to be ``isValid()`` as well — comparing
against ``None`` alone silently stops reporting a broken GL stack.
"""
super().paintEvent(event)
if self._unavailable or self._capabilities is not None:
return
if self.context() is None:
context = self.context()
if context is None or not context.isValid():
self._fail(
diagnostic(
"gl_unsupported",
@@ -305,7 +312,7 @@ class Sensor3DWidget(QOpenGLWidget):
# --------------------------------------------------------------- interaction
def mousePressEvent(self, event: QMouseEvent) -> None: # pragma: no cover
self._last_press = (float(event.x()), float(event.y()))
self._last_press = (float(event.position().x()), float(event.position().y()))
super().mousePressEvent(event)
def mouseReleaseEvent(self, event: QMouseEvent) -> None: # pragma: no cover
@@ -316,17 +323,17 @@ class Sensor3DWidget(QOpenGLWidget):
if self._last_press is None:
super().mouseMoveEvent(event)
return
x, y = float(event.x()), float(event.y())
x, y = float(event.position().x()), float(event.position().y())
delta = (x - self._last_press[0], y - self._last_press[1])
self._last_press = (x, y)
viewport = (max(self.width(), 1), max(self.height(), 1))
buttons = event.buttons()
panning = bool(buttons & Qt.MiddleButton) or bool(
buttons & Qt.LeftButton and event.modifiers() & Qt.ShiftModifier
panning = bool(buttons & Qt.MouseButton.MiddleButton) or bool(
buttons & Qt.MouseButton.LeftButton and event.modifiers() & Qt.KeyboardModifier.ShiftModifier
)
if panning:
self._camera.pan(delta, viewport)
elif buttons & Qt.LeftButton:
elif buttons & Qt.MouseButton.LeftButton:
self._camera.orbit(delta, viewport)
self.update()
@@ -26,7 +26,7 @@ import logging
import time
from typing import Any, Callable, Optional
from PyQt5.QtCore import QObject, QTimer, pyqtSignal
from PySide6.QtCore import QObject, QTimer, Signal
from orisys_app_shared.i18n import t
from orisys_studio import session_metadata
@@ -47,11 +47,11 @@ class SessionController(QObject):
"""Owns the acquisition session's state and its UI-free mechanics."""
#: The run timer fired: the window's presenter slot does the painting.
tick = pyqtSignal()
tick = Signal()
#: The phase was (re)applied, not only changed: the status chip repaints from it,
#: and an idempotent repaint is cheaper than a second source of truth.
phaseChanged = pyqtSignal(str)
runningChanged = pyqtSignal(bool)
phaseChanged = Signal(str)
runningChanged = Signal(bool)
def __init__(
self,
@@ -2,9 +2,9 @@
from __future__ import annotations
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QPixmap
from PyQt5.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
from PySide6.QtCore import Qt
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import logo_topbar_path
@@ -24,19 +24,19 @@ class AboutDialog(QDialog):
logo = QLabel()
pix = QPixmap(str(logo_topbar_path()))
if not pix.isNull():
logo.setPixmap(pix.scaledToHeight(48, Qt.SmoothTransformation))
logo.setPixmap(pix.scaledToHeight(48, Qt.TransformationMode.SmoothTransformation))
row.addWidget(logo)
row.addStretch(1)
root.addLayout(row)
self.body = QLabel()
self.body.setWordWrap(True)
self.body.setTextInteractionFlags(Qt.TextSelectableByMouse)
self.body.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
root.addWidget(self.body)
close_btn = QPushButton("OK")
close_btn.clicked.connect(self.accept)
root.addWidget(close_btn, alignment=Qt.AlignRight)
root.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight)
self.retranslate()
def retranslate(self) -> None:
+12 -12
View File
@@ -12,9 +12,9 @@ from __future__ import annotations
from typing import Optional
from PyQt5.QtCore import QSize, Qt, pyqtSignal
from PyQt5.QtGui import QImage, QPainter, QPixmap, QTransform
from PyQt5.QtWidgets import (
from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtGui import QImage, QPainter, QPixmap, QTransform
from PySide6.QtWidgets import (
QComboBox,
QFrame,
QHBoxLayout,
@@ -48,7 +48,7 @@ def bgr_to_qimage(frame) -> QImage:
"""
rgb = frame[:, :, ::-1].copy()
height, width, channels = rgb.shape
return QImage(rgb.data, width, height, channels * width, QImage.Format_RGB888).copy()
return QImage(rgb.data, width, height, channels * width, QImage.Format.Format_RGB888).copy()
_VIEW_LABELS = {
@@ -92,7 +92,7 @@ class AspectImageLabel(QLabel):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self._aspect = DEFAULT_FRAME_ASPECT
policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
policy.setHeightForWidth(True)
self.setSizePolicy(policy)
self.setMinimumSize(200, _IMAGE_MIN_HEIGHT)
@@ -132,7 +132,7 @@ class ColorBarLabel(QLabel):
self.setObjectName("colorBar")
self.setFixedHeight(_COLORBAR_HEIGHT)
self.setMinimumWidth(_COLORBAR_WIDTH)
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.setVisible(False)
def set_theme(self, theme: str) -> None:
@@ -145,14 +145,14 @@ class ColorBarLabel(QLabel):
# (256, 1, 3) BGR -> one RGB scanline. The bytes object stays alive for
# the whole call, which is what QImage's borrowed buffer needs.
rgb = flow_colormap(self._theme)[:, 0, ::-1].copy().tobytes()
image = QImage(rgb, 256, 1, 256 * 3, QImage.Format_RGB888)
image = QImage(rgb, 256, 1, 256 * 3, QImage.Format.Format_RGB888)
painter = QPainter(self)
painter.drawImage(self.rect(), image)
painter.end()
class ArrowPanel(QFrame):
view_mode_changed = pyqtSignal(str)
view_mode_changed = Signal(str)
def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent)
@@ -188,7 +188,7 @@ class ArrowPanel(QFrame):
layout.addLayout(header)
self.image_label = AspectImageLabel()
self.image_label.setAlignment(Qt.AlignCenter)
self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.image_label.setScaledContents(False)
self.image_label.setWordWrap(True)
# The image keeps the frame's shape and is centred in whatever height is
@@ -515,11 +515,11 @@ class ArrowPanel(QFrame):
deg = self._effective_rotation_deg()
if deg:
# QTransform.rotate is clockwise on screen pixmaps; negate for CCW.
pix = pix.transformed(QTransform().rotate(-deg), Qt.FastTransformation)
pix = pix.transformed(QTransform().rotate(-deg), Qt.TransformationMode.FastTransformation)
scaled = pix.scaled(
self.image_label.size(),
Qt.KeepAspectRatio,
Qt.SmoothTransformation,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
self.image_label.setPixmap(scaled)
self.image_label.setText("")
+12 -12
View File
@@ -14,9 +14,9 @@ from __future__ import annotations
import logging
from pathlib import Path
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtGui import QColor, QPainter, QPixmap
from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor, QPainter, QPixmap
from PySide6.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import logo_topbar_path
@@ -39,13 +39,13 @@ def tinted_pixmap(path: Path | str, tint: str, size: int = LOGO_SIZE) -> QPixmap
if source.isNull():
return QPixmap()
scaled = source.scaled(
size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation
size, size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation
)
out = QPixmap(scaled.size())
out.fill(Qt.transparent)
out.fill(Qt.GlobalColor.transparent)
painter = QPainter(out)
painter.drawPixmap(0, 0, scaled)
painter.setCompositionMode(QPainter.CompositionMode_SourceIn)
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn)
painter.fillRect(out.rect(), QColor(tint))
painter.end()
return out
@@ -55,11 +55,11 @@ class BrandBar(QFrame):
"""Thin brand rail: lockup and top-level view switcher, nothing else."""
#: The user asked for the live view.
live_requested = pyqtSignal()
live_requested = Signal()
#: The user asked for the history/replay view.
playback_requested = pyqtSignal()
playback_requested = Signal()
#: The user asked for the calibration view.
calibration_requested = pyqtSignal()
calibration_requested = Signal()
def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent)
@@ -74,7 +74,7 @@ class BrandBar(QFrame):
self.logo = QLabel()
self.logo.setObjectName("brandLogo")
self.logo.setFixedSize(LOGO_SIZE, LOGO_SIZE)
self.logo.setAlignment(Qt.AlignCenter)
self.logo.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.logo)
# The lockup keeps its two labels (the product's tagline stays available
@@ -113,7 +113,7 @@ class BrandBar(QFrame):
self.product = QLabel()
self.product.setObjectName("brandProduct")
self.product.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.product.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
layout.addWidget(self.product)
self.set_theme(theme)
@@ -123,7 +123,7 @@ class BrandBar(QFrame):
def _nav_button(self) -> QPushButton:
button = QPushButton()
button.setObjectName("navButton")
button.setCursor(Qt.PointingHandCursor)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setFlat(True)
button.setCheckable(True)
return button
+19 -19
View File
@@ -3,9 +3,9 @@
from __future__ import annotations
import numpy as np
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtGui import QColor, QFont, QFontDatabase
from PyQt5.QtWidgets import (
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor, QFont, QFontDatabase
from PySide6.QtWidgets import (
QAbstractItemView,
QButtonGroup,
QDoubleSpinBox,
@@ -60,11 +60,11 @@ def _mono_font() -> str:
class CalPanel(QFrame):
status_message = pyqtSignal(str)
status_message = Signal(str)
#: Coefficients were written to disk. It does **not** mean they were applied.
calibration_exported = pyqtSignal(str)
calibration_exported = Signal(str)
#: The operator asked to push the last export into the running device.
apply_requested = pyqtSignal(str)
apply_requested = Signal(str)
def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent)
@@ -97,9 +97,9 @@ class CalPanel(QFrame):
outer.setSpacing(0)
self.scroll = QScrollArea()
self.scroll.setWidgetResizable(True)
self.scroll.setFrameShape(QFrame.NoFrame)
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.scroll.setFrameShape(QFrame.Shape.NoFrame)
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
content = QWidget()
self.scroll.setWidget(content)
outer.addWidget(self.scroll)
@@ -135,7 +135,7 @@ class CalPanel(QFrame):
form = QFormLayout()
form.setHorizontalSpacing(10)
form.setVerticalSpacing(6)
form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter)
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self._weight_label = QLabel()
self._weight_label.setObjectName("fieldLabel")
self.weight_spin = QDoubleSpinBox()
@@ -182,16 +182,16 @@ class CalPanel(QFrame):
# Diagnostic table: the loaded axis plus the two crosstalk axes, a
# stability measure and the point's state.
self.table = QTableWidget(0, len(DIAGNOSTIC_COLUMNS))
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.table.setAlternatingRowColors(True)
self.table.setShowGrid(False)
header = self.table.horizontalHeader()
# Every column takes the width its own header and cells need: eliding a
# diagnostic column ("FN 均值" -> "FN 均") hides the meaning, so a narrow
# panel scrolls instead.
header.setSectionResizeMode(QHeaderView.ResizeToContents)
header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
header.setMinimumSectionSize(64)
self.table.verticalHeader().setVisible(False)
self.table.verticalHeader().setDefaultSectionSize(26)
@@ -235,7 +235,7 @@ class CalPanel(QFrame):
):
# The action row must survive a narrow three-panel layout.
button.setProperty("compact", "true")
button.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
button.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
self.btn_toggle_excluded.clicked.connect(self._on_toggle_excluded)
self.btn_toggle_validation.clicked.connect(self._on_toggle_validation)
self.btn_delete.clicked.connect(self._on_delete_row)
@@ -523,11 +523,11 @@ class CalPanel(QFrame):
)
for column, text in enumerate(values):
item = QTableWidgetItem(text)
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
if column in (0, 1, 2, 3):
item.setTextAlignment(int(Qt.AlignRight | Qt.AlignVCenter))
item.setTextAlignment(int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter))
else:
item.setTextAlignment(int(Qt.AlignCenter))
item.setTextAlignment(int(Qt.AlignmentFlag.AlignCenter))
if point.excluded:
item.setForeground(QColor(color(self._theme, "text_muted")))
elif point.is_validation:
@@ -611,7 +611,7 @@ class CalPanel(QFrame):
"""Double click a row to inspect that point's three-axis curve."""
dialog = self.point_dialog(self._current_point())
if dialog is not None:
dialog.exec_()
dialog.exec()
def _on_toggle_excluded(self) -> None:
point = self._current_point()
@@ -7,9 +7,9 @@ the three-panel layout, while a single point can still be examined in full.
from __future__ import annotations
import numpy as np
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QFont, QFontDatabase
from PyQt5.QtWidgets import (
from PySide6.QtCore import Qt
from PySide6.QtGui import QFont, QFontDatabase
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QFormLayout,
@@ -103,7 +103,7 @@ class CalibrationPointDialog(QDialog):
root.addWidget(self.plot_widget, 1)
self._build_traces()
buttons = QDialogButtonBox(QDialogButtonBox.Close)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
buttons.rejected.connect(self.reject)
buttons.accepted.connect(self.accept)
root.addWidget(buttons)
+3 -3
View File
@@ -9,8 +9,8 @@ clips the text and the tooltip remains the way to read it.
from __future__ import annotations
from PyQt5.QtCore import QSize
from PyQt5.QtWidgets import QLabel, QSizePolicy
from PySide6.QtCore import QSize
from PySide6.QtWidgets import QLabel, QSizePolicy
class ShrinkableLabel(QLabel):
@@ -18,7 +18,7 @@ class ShrinkableLabel(QLabel):
def __init__(self, text: str = "", parent=None) -> None:
super().__init__(text, parent)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
self.setMinimumWidth(0)
def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt naming
+13 -13
View File
@@ -5,9 +5,9 @@ from __future__ import annotations
import time
import numpy as np
from PyQt5.QtCore import Qt, QSize, pyqtSignal
from PyQt5.QtGui import QColor, QFont, QFontDatabase, QFontMetrics
from PyQt5.QtWidgets import (
from PySide6.QtCore import Qt, QSize, Signal
from PySide6.QtGui import QColor, QFont, QFontDatabase, QFontMetrics
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
@@ -116,18 +116,18 @@ class _Sync:
class ForcePlotsPanel(QFrame):
#: The visible time window changed while browsing history.
window_changed = pyqtSignal(float, float)
window_changed = Signal(float, float)
#: The shared cursor moved; other views sync their playback position to it.
cursor_moved = pyqtSignal(float)
cursor_moved = Signal(float)
#: "follow" (live) or "browse" (user-controlled window).
mode_changed = pyqtSignal(str)
mode_changed = Signal(str)
#: The unit chip was clicked: switch between newton and raw.
unit_toggle_requested = pyqtSignal()
unit_toggle_requested = Signal()
#: The gear was clicked: open the plot settings (window, range, filters).
plot_settings_requested = pyqtSignal()
plot_settings_requested = Signal()
#: The operator took the timeline over (dragged the cursor, box-zoomed, panned
#: or moved the overview marker): playback stops where it is.
timeline_taken_over = pyqtSignal()
timeline_taken_over = Signal()
def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent)
@@ -217,7 +217,7 @@ class ForcePlotsPanel(QFrame):
self.unit_badge = QPushButton()
self.unit_badge.setObjectName("unitBadge")
self.unit_badge.setProperty("compact", "true")
self.unit_badge.setCursor(Qt.PointingHandCursor)
self.unit_badge.setCursor(Qt.CursorShape.PointingHandCursor)
self.unit_badge.clicked.connect(self.unit_toggle_requested.emit)
header.addWidget(self.unit_badge)
root.addLayout(header)
@@ -329,7 +329,7 @@ class ForcePlotsPanel(QFrame):
self.window_label.setObjectName("panelMeta")
self.stats_label = QLabel()
self.stats_label.setObjectName("panelMeta")
self.stats_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
self.stats_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
browse_row.addWidget(self.window_caption)
browse_row.addWidget(self.window_label)
browse_row.addStretch(1)
@@ -348,7 +348,7 @@ class ForcePlotsPanel(QFrame):
button.setProperty("iconOnly", "true")
button.setIcon(icons.icon(icon_name, self._theme))
button.setIconSize(QSize(ICON_SIZE, ICON_SIZE))
button.setCursor(Qt.PointingHandCursor)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button._icon_name = icon_name # noqa: SLF001 - read back by set_theme
return button
@@ -389,7 +389,7 @@ class ForcePlotsPanel(QFrame):
value_row.setSpacing(5)
value_label = QLabel()
value_label.setObjectName("valueCard")
value_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
value_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
value_label.setFont(_mono_font(16, bold=True))
# Never a fixed height: the assertion that a value is not clipped is
# derived from the font it is actually drawn with.
+8 -8
View File
@@ -14,8 +14,8 @@ from __future__ import annotations
from functools import lru_cache
from PyQt5.QtCore import QRectF, Qt
from PyQt5.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
from PySide6.QtCore import QRectF, Qt
from PySide6.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
from orisys_app_shared.theme import color
@@ -50,7 +50,7 @@ def _paint_zoom_fit(painter: QPainter, size: int) -> None:
def _paint_box_zoom(painter: QPainter, size: int) -> None:
"""A dashed frame: drag a region to zoom into it."""
pen = painter.pen()
pen.setStyle(Qt.DashLine)
pen.setStyle(Qt.PenStyle.DashLine)
pen.setDashPattern([2.2, 1.6])
painter.setPen(pen)
painter.drawRect(QRectF(2.0, 2.0, size - 4.0, size - 4.0))
@@ -143,19 +143,19 @@ _PAINTERS = {
def pixmap(name: str, tint: str, size: int = ICON_SIZE) -> QPixmap:
"""One monochrome icon, drawn in ``tint`` (a ``#RRGGBB`` token value)."""
canvas = QPixmap(size, size)
canvas.fill(Qt.transparent)
canvas.fill(Qt.GlobalColor.transparent)
paint = _PAINTERS.get(str(name))
if paint is None:
return canvas
painter = QPainter(canvas)
painter.setRenderHint(QPainter.Antialiasing, True)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
pen = QPen()
pen.setWidthF(_PEN_WIDTH)
pen.setCapStyle(Qt.RoundCap)
pen.setJoinStyle(Qt.RoundJoin)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
pen.setColor(QColor(tint))
painter.setPen(pen)
painter.setBrush(Qt.NoBrush)
painter.setBrush(Qt.BrushStyle.NoBrush)
try:
paint(painter, size)
finally:
@@ -16,8 +16,8 @@ from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Callable, Optional
from PyQt5.QtCore import QSize, Qt, pyqtSignal
from PyQt5.QtWidgets import (
from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
@@ -78,7 +78,7 @@ class Mesh3dPanel(QFrame):
#: Emitted when the operator turns the pane on. The window wires the packet sink
#: from this, so no packet is adapted while the pane is off.
enabledChanged = pyqtSignal(bool)
enabledChanged = Signal(bool)
def __init__(
self, parent: Optional[QWidget] = None, *, theme: str = "dark"
@@ -128,14 +128,14 @@ class Mesh3dPanel(QFrame):
self.btn_reset_view = QPushButton()
self.btn_reset_view.setProperty("compact", "true")
self.btn_reset_view.setProperty("iconOnly", "true")
self.btn_reset_view.setCursor(Qt.PointingHandCursor)
self.btn_reset_view.setCursor(Qt.CursorShape.PointingHandCursor)
self.btn_reset_view.clicked.connect(self.reset_view)
header.addWidget(self.btn_reset_view)
self.btn_settings = QPushButton()
self.btn_settings.setProperty("compact", "true")
self.btn_settings.setProperty("iconOnly", "true")
self.btn_settings.setCursor(Qt.PointingHandCursor)
self.btn_settings.setCursor(Qt.CursorShape.PointingHandCursor)
self.btn_settings.clicked.connect(self.open_settings)
header.addWidget(self.btn_settings)
root.addLayout(header)
@@ -145,7 +145,7 @@ class Mesh3dPanel(QFrame):
self.message = QLabel()
self.message.setObjectName("hintText")
self.message.setAlignment(Qt.AlignCenter)
self.message.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.message.setWordWrap(True)
self._stack.addWidget(self.message)
@@ -9,7 +9,7 @@ reports as done is what the record will say (see ``force/filters.py``).
from __future__ import annotations
from PyQt5.QtWidgets import (
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
@@ -133,7 +133,7 @@ class PlotSettingsDialog(QDialog):
self.btn_reset.clicked.connect(self._reset_defaults)
buttons_row.addWidget(self.btn_reset)
buttons_row.addStretch(1)
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
self.button_box.accepted.connect(self.accept)
self.button_box.rejected.connect(self.reject)
buttons_row.addWidget(self.button_box)
@@ -384,8 +384,8 @@ class PlotSettingsDialog(QDialog):
self.rate_label.setText(t("dialog.filter_rate"))
self.rate_spin.setToolTip(t("dialog.filter_rate_tip"))
self._refresh_filter_note()
ok_btn = self.button_box.button(QDialogButtonBox.Ok)
cancel_btn = self.button_box.button(QDialogButtonBox.Cancel)
ok_btn = self.button_box.button(QDialogButtonBox.StandardButton.Ok)
cancel_btn = self.button_box.button(QDialogButtonBox.StandardButton.Cancel)
if ok_btn is not None:
ok_btn.setText(t("dialog.ok"))
if cancel_btn is not None:
@@ -14,7 +14,7 @@ timer without touching the writer thread.
from __future__ import annotations
from PyQt5.QtWidgets import QLabel
from PySide6.QtWidgets import QLabel
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color
@@ -17,7 +17,7 @@ from __future__ import annotations
from datetime import datetime
from pathlib import Path
from PyQt5.QtWidgets import (
from PySide6.QtWidgets import (
QDialog,
QFrame,
QHBoxLayout,
@@ -111,7 +111,7 @@ class SensorDialog(QDialog):
def _section_rule() -> QFrame:
rule = QFrame()
rule.setObjectName("sectionSeparator")
rule.setFrameShape(QFrame.HLine)
rule.setFrameShape(QFrame.Shape.HLine)
rule.setFixedHeight(1)
return rule
@@ -9,8 +9,8 @@ own labels and tooltips instead of a bare "配置" combo.
from __future__ import annotations
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import (
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QComboBox,
QHBoxLayout,
QLabel,
@@ -29,8 +29,8 @@ BADGE_WIDTH = 46
class SensorPickerWidget(QWidget):
sensor_type_changed = pyqtSignal(str)
config_changed = pyqtSignal(str)
sensor_type_changed = Signal(str)
config_changed = Signal(str)
def __init__(self, parent=None) -> None:
super().__init__(parent)
@@ -42,7 +42,7 @@ class SensorPickerWidget(QWidget):
#: attribute: it states which sensor the whole window is configured for.
self.thumb_label = QLabel()
self.thumb_label.setObjectName("unitBadge")
self.thumb_label.setAlignment(Qt.AlignCenter)
self.thumb_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
# A minimum, not a fixed width: "FINGER" must not be truncated to "INGE".
self.thumb_label.setMinimumWidth(BADGE_WIDTH)
self.thumb_label.setProperty("rail", "true")
@@ -55,7 +55,7 @@ class SensorPickerWidget(QWidget):
self.type_combo = QComboBox()
self.type_combo.setMinimumWidth(TYPE_WIDTH)
self.type_combo.setProperty("rail", "true")
self.type_combo.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.type_combo.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
for name in list_sensor_types():
self.type_combo.addItem(name, name)
self.type_combo.currentIndexChanged.connect(self._on_type_index_changed)
@@ -65,7 +65,7 @@ class SensorPickerWidget(QWidget):
self.config_combo = QComboBox()
self.config_combo.setMinimumWidth(CONFIG_WIDTH)
self.config_combo.setProperty("rail", "true")
self.config_combo.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.config_combo.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
self.config_combo.currentIndexChanged.connect(self._on_config_index_changed)
layout.addWidget(self.thumb_label)
+20 -20
View File
@@ -12,8 +12,8 @@ scrolls sideways instead of squeezing the controls into an unreadable row.
from __future__ import annotations
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import (
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QComboBox,
QFrame,
QHBoxLayout,
@@ -42,9 +42,9 @@ RECORD_MODES = (("video_data", "record.mode_video_data"), ("data_only", "record.
def _separator() -> QFrame:
line = QFrame()
line.setObjectName("sectionSeparator")
line.setFrameShape(QFrame.VLine)
line.setFrameShape(QFrame.Shape.VLine)
line.setFixedWidth(1)
line.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
line.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding)
return line
@@ -70,23 +70,23 @@ def _group(*widgets: QWidget) -> QWidget:
class ToolbarWidget(QWidget):
search_clicked = pyqtSignal()
preview_toggled = pyqtSignal(bool)
camera_id_changed = pyqtSignal(int)
search_clicked = Signal()
preview_toggled = Signal(bool)
camera_id_changed = Signal(int)
#: The sensor summary button: opens the sensor and calibration dialog.
sensor_clicked = pyqtSignal()
sensor_clicked = Signal()
#: "Back to the camera" while a video file is the run source.
use_camera_clicked = pyqtSignal()
use_camera_clicked = Signal()
#: Replay view: pick the recorded video to play.
replay_video_clicked = pyqtSignal()
replay_video_clicked = Signal()
#: Replay view: re-run the pipeline over that video (the only processed picture).
recompute_clicked = pyqtSignal()
run_toggled = pyqtSignal(bool)
reset_clicked = pyqtSignal()
units_changed = pyqtSignal(str)
record_toggled = pyqtSignal(bool)
recompute_clicked = Signal()
run_toggled = Signal(bool)
reset_clicked = Signal()
units_changed = Signal(str)
record_toggled = Signal(bool)
#: The settings area was expanded or collapsed by the user.
settings_toggled = pyqtSignal(bool)
settings_toggled = Signal(bool)
def __init__(
self,
@@ -124,9 +124,9 @@ class ToolbarWidget(QWidget):
# narrow window.
self._settings_scroll = QScrollArea()
self._settings_scroll.setWidgetResizable(True)
self._settings_scroll.setFrameShape(QFrame.NoFrame)
self._settings_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self._settings_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self._settings_scroll.setFrameShape(QFrame.Shape.NoFrame)
self._settings_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self._settings_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.settings_page = self._build_settings()
self._settings_scroll.setWidget(self.settings_page)
outer.addWidget(self._settings_scroll)
@@ -157,7 +157,7 @@ class ToolbarWidget(QWidget):
self.status_chip = QLabel()
self.status_chip.setObjectName("statusChip")
self.status_chip.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred)
self.status_chip.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred)
self.source_summary = ShrinkableLabel()
self.source_summary.setObjectName("panelMeta")
self.calibration_label = ShrinkableLabel()
+1 -1
View File
@@ -24,7 +24,7 @@ import logging
from dataclasses import dataclass
from typing import Any, Optional, Protocol
from PyQt5.QtCore import QObject
from PySide6.QtCore import QObject
logger = logging.getLogger(__name__)
+25 -2
View File
@@ -3,6 +3,10 @@
The tests render real Qt widgets with the offscreen platform plugin, so they need
no display and no camera hardware. A single ``QApplication`` is shared by the
whole session (Qt forbids creating more than one).
The suite requires PySide6, the app's only Qt binding. Where it is missing the
modules are skipped rather than failed, so a broken environment cannot look like a
broken application (the same rule ``apps/autocal`` follows).
"""
from __future__ import annotations
@@ -16,11 +20,30 @@ from pathlib import Path
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
# In a development environment that also has PyQt5 installed, pyqtgraph would otherwise
# bind to it (it takes whichever toolkit it finds first) and the plots would break.
os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6")
if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
_fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
if _fonts.is_dir():
os.environ["QT_QPA_FONTDIR"] = str(_fonts)
def _have_pyside6() -> tuple[bool, str]:
try:
import PySide6
except ImportError as exc: # pragma: no cover - depends on the machine
return False, str(exc)
return True, getattr(PySide6, "__version__", "unknown")
HAVE_PYSIDE6, PYSIDE6_VERSION = _have_pyside6()
if not HAVE_PYSIDE6: # pragma: no cover - depends on the machine
# Mirrors apps/autocal/tests/conftest.py: nothing is collected, so a machine without
# the binding reports no tests instead of a wall of import errors.
collect_ignore_glob = ["test_*.py"]
# ``tools/ui_check.py`` owns the palette rules; importing it keeps one source of
# truth between the pytest suite and the dependency-free checker.
_TOOLS_DIR = Path(__file__).resolve().parents[1] / "tools"
@@ -30,7 +53,7 @@ if _TOOLS_DIR.is_dir() and str(_TOOLS_DIR) not in sys.path:
@pytest.fixture(scope="session")
def qt_app():
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
yield app
@@ -83,7 +106,7 @@ def visible_descendants(widget):
def show_window(window, size):
"""Show a window at ``size`` and flush pending Qt events."""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
window.resize(*size)
window.show()
+224
View File
@@ -0,0 +1,224 @@
"""Binding policy for the studio.
The studio is **PySide6 only** — Qt's LGPLv3 binding, which is why the app was migrated off
PyQt5 (GPLv3-or-commercial). AutoCal has the same guard in
``apps/autocal/tests/test_binding_purity.py``; this file is the studio's half of it:
* no module may import PyQt5, PySide2 or PyQt6 — the app's environment has PySide6 and
nothing else, so such an import is a hard start-up failure;
* every UI and 3D module must name the binding it uses rather than inheriting one;
* the PyQt5 signal spellings (``pyqtSignal``, ``pyqtBoundSignal``) do not exist in PySide6;
* no module may use an unscoped Qt enumerator (``Qt.AlignLeft``, ``QPainter.Antialiasing``);
* importing the application must not drag another binding into ``sys.modules`` — that is
what breaks ``pyqtgraph``, which binds to whichever toolkit it finds first.
Two lessons from the migration are baked in here. The unscoped-enum check asks PySide6
instead of using a hand-written list of enum holders: a curated list missed
``QPainter.RenderHint.Antialiasing``, ``QPainter.CompositionMode.CompositionMode_SourceIn`` and
``QDialogButtonBox.StandardButton.Ok|Cancel|Close``. And the ``sys.modules`` check runs in a **fresh
interpreter**, because in-process the answer depends on what some earlier test imported.
"""
from __future__ import annotations
import ast
import enum
import subprocess
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1] # apps/studio
SRC = APP / "src"
PROJECT_ROOT = APP.parents[1] # orisys-app
#: Everything that ships or drives the app.
SCAN_ROOTS = (SRC, APP / "tools")
#: Bindings no module may import.
FORBIDDEN_BINDINGS = {"PyQt5", "PyQt6", "PySide2"}
#: The one binding allowed.
ALLOWED_BINDING = "PySide6"
#: Directories that must name their binding explicitly.
#:
#: ``ui/`` only: every widget there imports PySide6. ``scene3d/`` is deliberately mixed —
#: ``coordinator.py``, ``camera.py``, ``adapters.py``, ``prefs.py`` and ``keys.py`` import no
#: Qt binding at all so they can be tested headless, and requiring one would forbid that
#: design. The modules there that *do* use Qt already name PySide6.
BINDING_DECLARING_DIRS = (SRC / "orisys_studio" / "ui",)
#: Names that only ever existed in PyQt. Quoted here on purpose — the migration scripts that
#: do the rewriting skip this file, because a text-based rule cannot tell a forbidden spelling
#: in code from one named in the policy that forbids it.
PYQT_ONLY_NAMES = ("pyqtSignal", "pyqtBoundSignal", "pyqtSlot", "pyqtProperty")
_SKIP_PARTS = ("__pycache__",)
def _python_files(root: Path) -> list[Path]:
return sorted(
path
for path in root.rglob("*.py")
if not any(part in path.parts for part in _SKIP_PARTS)
)
def _read(path: Path) -> str:
# One file in the workspace carries a UTF-8 BOM; `utf-8-sig` keeps `ast` happy either way.
return path.read_text(encoding="utf-8-sig", errors="replace")
def _imported_packages(path: Path) -> set[str]:
"""Every top-level package name imported by ``path``."""
found: set[str] = set()
for node in ast.walk(ast.parse(_read(path))):
if isinstance(node, ast.Import):
found.update(alias.name.split(".")[0] for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
found.add(node.module.split(".")[0])
return found
def _qt_namespace() -> dict[str, object]:
"""The Qt classes the sources could name, resolved through the binding itself."""
from PySide6 import QtCore, QtGui, QtOpenGLWidgets, QtWidgets
namespace: dict[str, object] = {"Qt": QtCore.Qt}
return namespace, (QtCore, QtGui, QtWidgets, QtOpenGLWidgets)
def _unscoped_enums(path: Path, namespace: dict[str, object], modules: tuple) -> list[str]:
"""Attribute reads whose value resolves to an enum instance rather than an enum class.
``Qt.AlignmentFlag.AlignLeft`` is an inner attribute of an enum class, so the outer
``Qt.AlignmentFlag`` resolves to a *class* and the inner one is not a plain name — neither
is a candidate. ``QMessageBox.information`` resolves to a method. ``Qt.AlignmentFlag.AlignLeft``
resolves to an enum instance, which is the spelling this test exists to reject.
"""
tree = ast.parse(_read(path))
called = {
id(node.func)
for node in ast.walk(tree)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
}
inner = {id(node.value) for node in ast.walk(tree) if isinstance(node, ast.Attribute)}
found: set[str] = set()
for node in ast.walk(tree):
if not isinstance(node, ast.Attribute) or not isinstance(node.value, ast.Name):
continue
if id(node) in called or id(node) in inner:
continue
holder, member = node.value.id, node.attr
target_holder = namespace.get(holder)
if target_holder is None:
for module in modules:
if hasattr(module, holder):
target_holder = namespace[holder] = getattr(module, holder)
break
else:
continue
value = getattr(target_holder, member, None)
if isinstance(value, (enum.Enum, enum.Flag)) and member not in namespace:
found.add(f"{holder}.{member}")
return sorted(found)
def test_no_studio_module_imports_a_forbidden_binding():
offenders = []
for root in SCAN_ROOTS:
for path in _python_files(root):
bad = _imported_packages(path) & FORBIDDEN_BINDINGS
if bad:
offenders.append(f"{path.relative_to(APP)}: {sorted(bad)}")
assert not offenders, (
f"the studio is {ALLOWED_BINDING}-only; these modules import another binding: "
f"{offenders}"
)
def test_every_ui_module_imports_the_allowed_binding():
"""A widget module that names no binding would be using one it does not declare."""
offenders = []
for directory in BINDING_DECLARING_DIRS:
for path in _python_files(directory):
if path.name == "__init__.py":
continue # a re-export barrel, not a widget module
if ALLOWED_BINDING not in _imported_packages(path):
offenders.append(str(path.relative_to(APP)))
assert not offenders, f"these modules do not import {ALLOWED_BINDING}: {offenders}"
def test_no_pyqt_only_spelling_survives():
offenders = []
for root in SCAN_ROOTS:
for path in _python_files(root):
text = _read(path)
for name in PYQT_ONLY_NAMES:
if name in text:
offenders.append(f"{path.relative_to(APP)}: {name}")
assert not offenders, f"these PyQt-only names do not exist in PySide6: {offenders}"
def test_no_module_uses_an_unscoped_enum():
try:
import PySide6 # noqa: F401
except ImportError: # pragma: no cover - depends on the machine
import pytest
pytest.skip("PySide6 is not installed here, so the enum rule cannot be checked")
namespace, modules = _qt_namespace()
offenders = {}
for root in SCAN_ROOTS:
for path in _python_files(root):
found = _unscoped_enums(path, namespace, modules)
if found:
offenders[str(path.relative_to(APP))] = found
assert not offenders, (
"unscoped Qt enumerators are the spelling PySide6 is removing; use the scoped "
f"form: {offenders}"
)
def test_importing_the_application_pulls_in_no_other_binding():
"""Fresh interpreter: PySide6 only, whatever this test session imported before."""
import os
script = (
"import sys;"
"import orisys_studio.app_window;"
"import orisys_studio.scene3d.view;"
"import orisys_studio.ui.force_plots;"
"print(','.join(m for m in ('PyQt5', 'PyQt6', 'PySide2') if m in sys.modules))"
)
env = dict(os.environ)
entries = [str(PROJECT_ROOT / "shared" / "src"), str(SRC)]
if env.get("PYTHONPATH"):
entries.append(env["PYTHONPATH"])
env["PYTHONPATH"] = os.pathsep.join(entries)
# The app's own start-up contract (``main.py``) and a display-free platform: the point
# is which *binding* comes in, not whether a window can be shown.
env.setdefault("PYQTGRAPH_QT_LIB", "PySide6")
env.setdefault("QT_QPA_PLATFORM", "offscreen")
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
timeout=180,
check=False,
env=env,
)
assert result.returncode == 0, result.stderr
pulled = [name for name in result.stdout.strip().split(",") if name]
assert pulled == [], (
f"importing the app pulled in {pulled}; pyqtgraph binds to whichever toolkit it "
"finds first, so a second binding in the environment breaks the plots"
)
def test_the_app_declares_its_binding_dependency():
"""The packaging must state PySide6, not leave it to chance."""
pyproject = (APP / "pyproject.toml").read_text(encoding="utf-8")
assert ALLOWED_BINDING in pyproject
for forbidden in FORBIDDEN_BINDINGS:
assert f'"{forbidden}' not in pyproject, forbidden
+4 -3
View File
@@ -94,15 +94,16 @@ def _render(widget, qt_app) -> np.ndarray:
on a little-endian machine, with a stride that can exceed the width — so the
conversion and the stride trim are both load-bearing.
"""
from PyQt5.QtGui import QImage
from PySide6.QtGui import QImage
widget.repaint()
qt_app.processEvents()
image = widget.grabFramebuffer()
assert not image.isNull(), "Qt returned no framebuffer"
image = image.convertToFormat(QImage.Format_RGB888)
image = image.convertToFormat(QImage.Format.Format_RGB888)
width, height = image.width(), image.height()
buffer = image.constBits().asarray(image.byteCount())
# PySide6 returns a `memoryview` from constBits(), not a QByteArray: wrap it directly.
buffer = np.frombuffer(image.constBits(), dtype=np.uint8)
stride = image.bytesPerLine()
rows = np.frombuffer(buffer, dtype=np.uint8).reshape(height, stride)
return rows[:, : width * 3].reshape(height, width, 3).copy()
+6 -6
View File
@@ -13,8 +13,8 @@ view menu / the pane's header both reach that dialog.
from __future__ import annotations
import pytest
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtWidgets import QWidget
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QWidget
from orisys_app_shared.i18n import t
from orisys_studio.ui import icons
@@ -117,8 +117,8 @@ def test_the_pane_header_offers_the_two_actions(qt_app):
# Reset forwards to the view when there is one.
class _View(QWidget):
diagnosticRaised = pyqtSignal(object)
enabledChanged = pyqtSignal(bool)
diagnosticRaised = Signal(object)
enabledChanged = Signal(bool)
def __init__(self) -> None:
super().__init__()
@@ -229,7 +229,7 @@ def test_the_diagnostics_switch_governs_what_the_pane_explains(panel):
def test_the_2d_footer_shows_the_picture_and_hides_the_pipeline(make_window):
"""One line under the canvas: what the picture is, not how it was made."""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
window = make_window()
window.show()
@@ -262,7 +262,7 @@ def test_the_2d_footer_shows_the_picture_and_hides_the_pipeline(make_window):
def test_the_view_menu_opens_the_3d_settings(make_window):
"""视图 ▸ 三维显示设置… is the way in that does not depend on the pane's header."""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
window = make_window(sensor_type="finger", show_finger_3d=True)
window.show()
+5 -5
View File
@@ -19,7 +19,7 @@ import pytest
@pytest.fixture
def restore_surface_format():
"""The default surface format is process-wide; put it back afterwards."""
from PyQt5.QtGui import QSurfaceFormat
from PySide6.QtGui import QSurfaceFormat
previous = QSurfaceFormat.defaultFormat()
try:
@@ -49,21 +49,21 @@ def test_the_skeleton_does_not_import_a_gl_binding(qt_app):
def test_configure_surface_format_selects_a_complete_profile(qt_app, restore_surface_format):
from PyQt5.QtGui import QSurfaceFormat
from PySide6.QtGui import QSurfaceFormat
from orisys_studio.scene3d.gl_backend import configure_surface_format
configure_surface_format("desktop33")
desktop = QSurfaceFormat.defaultFormat()
assert desktop.renderableType() == QSurfaceFormat.OpenGL
assert desktop.renderableType() == QSurfaceFormat.RenderableType.OpenGL
assert (desktop.majorVersion(), desktop.minorVersion()) == (3, 3)
assert desktop.profile() == QSurfaceFormat.CoreProfile
assert desktop.profile() == QSurfaceFormat.OpenGLContextProfile.CoreProfile
assert desktop.depthBufferSize() == 24
assert desktop.samples() == 0, "v1 does not pay for multisampling"
configure_surface_format("gles30")
embedded = QSurfaceFormat.defaultFormat()
assert embedded.renderableType() == QSurfaceFormat.OpenGLES
assert embedded.renderableType() == QSurfaceFormat.RenderableType.OpenGLES
assert (embedded.majorVersion(), embedded.minorVersion()) == (3, 0)
with pytest.raises(ValueError):
+11 -6
View File
@@ -29,8 +29,8 @@ import numpy as np
import pytest
import ui_check
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtWidgets import QWidget
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QWidget
from orisys_app_shared.i18n import t
from orisys_app_shared.scene3d import synthetic
from orisys_app_shared.scene3d.contracts import (
@@ -276,8 +276,8 @@ class _RecordingView(QWidget):
a real ``QWidget`` because the container puts it in its stack.
"""
diagnosticRaised = pyqtSignal(object)
enabledChanged = pyqtSignal(bool)
diagnosticRaised = Signal(object)
enabledChanged = Signal(bool)
def __init__(self, parent=None) -> None:
super().__init__(parent)
@@ -559,7 +559,9 @@ def test_a_package_is_loaded_off_the_gui_thread(qt_app, write_poc):
loaded: list = []
failed: list = []
loader.loaded.connect(lambda assets, description: loaded.append((assets, description)))
loader.failed.connect(failed.append)
# Same arity caveat as below: this branch should never fire, and under PySide6 a
# builtin slot would fail *silently* if it ever did.
loader.failed.connect(lambda item, request_id: failed.append(item))
try:
loader.load(SceneSource(path=path, kind="package", directory=path.parent))
assert _pump(qt_app, lambda: bool(loaded or failed)), "the loader never answered"
@@ -679,7 +681,10 @@ def test_a_corrupt_package_is_reported_not_raised(qt_app, tmp_path):
broken.write_bytes(b"not a zip archive at all")
loader = SceneAssetLoader()
failed: list = []
loader.failed.connect(failed.append)
# ``failed`` carries (diagnostic, request id). PySide6 adapts to a *Python* callable's
# arity but not to a builtin's, so ``failed.append`` raises TypeError inside the emit,
# Qt swallows it, and the failure signal looks like it was never sent.
loader.failed.connect(lambda item, request_id: failed.append(item))
try:
loader.load(SceneSource(path=broken, kind="package", directory=tmp_path))
assert _pump(qt_app, lambda: bool(failed)), "a corrupt package produced no answer"
+29 -29
View File
@@ -559,7 +559,7 @@ def test_force_plots_render_demo_data(make_window):
window = _show(make_window(), (1440, 880))
for i in range(60):
window.force_plots.push(i / 30.0, float(i), -1.0, 1.0, unit="N")
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
QApplication.processEvents()
assert window.force_plots.history.sample_count > 0
@@ -1009,7 +1009,7 @@ def test_top_area_stays_compact(make_window):
assert window.toolbar.record_chip.text() == t("record.state_idle")
window.toolbar.btn_settings.setChecked(True)
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
QApplication.processEvents()
assert window.toolbar.settings_expanded()
@@ -1032,7 +1032,7 @@ def test_settings_area_stays_inside_its_container(make_window, size):
The settings area is the one part of the top rail that may scroll: its own
content is exempt, everything else must fit.
"""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
window = _show(make_window(sensor_type="finger", show_cal_panel=True), size)
window._set_settings_expanded(True)
@@ -1063,7 +1063,7 @@ def test_idle_run_is_the_only_filled_action(make_window):
def test_connecting_cancel_stays_filled(make_window):
"""Cancel is the way out of a hung device open, so it keeps the weight."""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880))
stuck = ui_check._StuckWorker()
@@ -1197,7 +1197,7 @@ def test_overview_region_is_the_timeline_control(make_window):
def test_the_charts_start_in_follow_mode(make_window):
"""Every live entry point resumes following; only browsing is a gesture."""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
@@ -1229,7 +1229,7 @@ def test_the_charts_start_in_follow_mode(make_window):
def test_the_charts_zoom_and_pan_in_every_view(make_window):
"""Zoom/pan must be reachable from the live view, not only while browsing."""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
window, stuck = _running_window(make_window)
panel = window.force_plots
@@ -1306,7 +1306,7 @@ def test_the_arrow_legend_follows_the_view_mode(make_window):
def test_the_sensor_summary_opens_the_dialog(make_window):
"""The rail states the sensor; the pickers live in the dialog it opens."""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
@@ -1344,7 +1344,7 @@ def test_the_sensor_summary_opens_the_dialog(make_window):
def test_the_source_picker_follows_the_view(make_window):
"""Live offers a camera, the replay a recorded video — and no residue."""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
window, stuck = _running_window(make_window)
assert window.toolbar.camera_group.isVisible()
@@ -1425,11 +1425,11 @@ def test_the_nav_items_switch_the_main_window_layout(make_window):
def test_the_calibration_view_does_not_clip_its_controls(make_window):
from PyQt5.QtWidgets import QHeaderView
from PySide6.QtWidgets import QHeaderView
window = _show(make_window(sensor_type="box", show_cal_panel=True), (1440, 880))
window.brand_bar.btn_calibration.click()
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
for _ in range(3):
QApplication.processEvents()
@@ -1437,7 +1437,7 @@ def test_the_calibration_view_does_not_clip_its_controls(make_window):
cal = window.cal_panel
assert cal.scroll.isVisible(), "the calibration panel cannot scroll"
assert (
cal.table.horizontalHeader().sectionResizeMode(0) == QHeaderView.ResizeToContents
cal.table.horizontalHeader().sectionResizeMode(0) == QHeaderView.ResizeMode.ResizeToContents
), "the diagnostic columns can still be elided"
for button in (cal.btn_apply, cal.btn_export, cal.btn_toggle_validation):
assert button.height() >= button.fontMetrics().height(), button.text()
@@ -1468,7 +1468,7 @@ def test_the_replay_view_is_the_live_view_with_a_video_source(make_window):
window = _show(make_window(), (1440, 880))
window.brand_bar.btn_live.click()
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
QApplication.processEvents()
live_sizes = tuple(window.content_splitter.sizes())
@@ -1496,7 +1496,7 @@ def test_the_replay_view_is_the_live_view_with_a_video_source(make_window):
def test_the_replay_view_loads_a_recording_and_scrubs_it(make_window, replay_recording):
"""The replay view reads the recording: charts, 2D panel and transport."""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
@@ -1602,7 +1602,7 @@ def test_the_unit_chip_switches_the_charts_and_the_toolbar(make_window):
from orisys_app_shared.i18n import t
from orisys_studio.ui.force_plots import unit_display
from PyQt5.QtWidgets import QPushButton
from PySide6.QtWidgets import QPushButton
window = _show(make_window(), (1440, 880))
plots = window.force_plots
@@ -1734,7 +1734,7 @@ def test_a_replay_keeps_the_2d_pane_from_the_live_camera(
While the replay view is up, its frames own the 2D panel; a live packet used
to paint over the recording. Leaving the view hands the pane back.
"""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
window, stuck = _running_window(make_window)
window.brand_bar.btn_playback.click()
@@ -1786,7 +1786,7 @@ def test_the_readout_describes_the_point_the_cursor_is_on(
A replay used to state the live acquisition in cards sitting next to a chart
of 7.5e5 raw counts, which reads as "the replay never opened".
"""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
@@ -1820,7 +1820,7 @@ def test_a_raw_only_recording_is_never_shown_in_newton(make_window, replay_recor
for the axes it calibrated. Asking for newton anyway drew three blank charts
and a dash in every card, which looks exactly like "the replay did not load".
"""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
@@ -1847,7 +1847,7 @@ def test_the_replay_transport_plays_and_pauses(make_window, replay_recording):
"""Play/pause like an editor: 1× forward, and a manual gesture takes over."""
import time
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880))
window.brand_bar.btn_playback.click()
@@ -1887,7 +1887,7 @@ def test_the_recompute_mode_reruns_the_pipeline_over_the_video(
make_window, replay_recording
):
"""Off: the recorded CSV. On: the recorded video through the vision chain."""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880))
window.brand_bar.btn_playback.click()
@@ -1929,7 +1929,7 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec
wrong. So the rail's second button becomes an unconditional stop in this view, and
it covers both things that can run there: plain playback and a recompute pass.
"""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
@@ -1989,7 +1989,7 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec
def test_escape_stops_the_replay_too(make_window, replay_recording):
"""Esc is the documented way out; in the replay view it stops the replay."""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880))
window.brand_bar.btn_playback.click()
@@ -2007,7 +2007,7 @@ def test_escape_stops_the_replay_too(make_window, replay_recording):
def test_the_live_view_keeps_the_tracker_reset(make_window):
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
window = _show(make_window(), (1440, 880))
window.brand_bar.btn_live.click()
@@ -2028,7 +2028,7 @@ def test_a_bare_video_plays_even_though_it_is_not_a_recording(make_window, recor
import cv2
import numpy as np
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
@@ -2091,7 +2091,7 @@ def test_the_replay_and_the_live_paths_share_one_image_backend(make_window, reco
"""
import cv2
import numpy as np
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
import orisys_studio.app_window as app_module
from orisys_app_shared.acquisition.image_backend import (
@@ -2186,7 +2186,7 @@ def test_the_replay_entries_exist_only_in_the_replay_view(make_window, record_ro
"""
import cv2
import numpy as np
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
@@ -2239,7 +2239,7 @@ def test_a_file_that_is_not_a_video_at_all_is_refused_with_a_reason(
make_window, record_root
):
"""Not a recording *and* not decodable: say what is missing and offer recompute."""
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
@@ -2272,7 +2272,7 @@ def test_a_file_that_is_not_a_video_at_all_is_refused_with_a_reason(
def test_a_video_inside_a_recording_opens_the_recording_and_plays(
make_window, replay_recording
):
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
@@ -2300,7 +2300,7 @@ def test_a_recording_without_a_video_says_it_cannot_be_played(make_window, recor
import csv
import json
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import t
@@ -2399,7 +2399,7 @@ def test_the_status_bar_is_a_notice_line_not_a_state_line(make_window):
"""
import time
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
import orisys_studio.app_window as app_module
+5 -5
View File
@@ -48,7 +48,7 @@ def check(condition: bool, message: str) -> None:
def _inside_scroll_area(widget) -> bool:
"""True when ``widget`` lives inside a QScrollArea's content."""
from PyQt5.QtWidgets import QScrollArea
from PySide6.QtWidgets import QScrollArea
node = widget
while node is not None:
@@ -538,7 +538,7 @@ def audit_palette(verbose: bool = False) -> list[str]:
def main() -> int:
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
from orisys_app_shared.force.calibration_io import CalSample
from orisys_app_shared.i18n import set_locale, t
@@ -1491,7 +1491,7 @@ def main() -> int:
window.brand_bar.btn_calibration.click()
for _ in range(3):
app.processEvents()
from PyQt5.QtWidgets import QHeaderView as _QHeaderView
from PySide6.QtWidgets import QHeaderView as _QHeaderView
sizes = window.content_splitter.sizes()
check(
@@ -2000,7 +2000,7 @@ def main() -> int:
"import os, sys\n"
"os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')\n"
"os.environ.setdefault('QT_QPA_FONTDIR', r'C:\\Windows\\Fonts')\n"
"from PyQt5.QtCore import QTimer\n"
"from PySide6.QtCore import QTimer\n"
"import orisys_studio.main as studio_main\n"
"show = studio_main.StudioMainWindow.show\n"
"def show_and_close(self):\n"
@@ -2259,7 +2259,7 @@ def main() -> int:
import orisys_studio.ui.cal_panel as _cal_panel_module
import orisys_studio.calibration_service as _cal_service_module
from PyQt5.QtWidgets import QMessageBox as _QMessageBox
from PySide6.QtWidgets import QMessageBox as _QMessageBox
from orisys_app_shared.force.calibration_io import (
build_force_calibration_v2 as _build_v2b,
+1 -1
View File
@@ -391,7 +391,7 @@ def main() -> int:
panels = {part.strip().lower() for part in args.panels.split(",") if part.strip()}
from PyQt5.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import set_locale
from orisys_app_shared.theme import build_qss