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