界面改用「精密仪器工作台」布局:顶栏压缩、视图切布局、标定与回放面板不再裁切
- What: 品牌条 40→28px 并加 实时监测/历史回放/标定 导航;导航改为切换主界面三套布局(实时=2D图像+曲线、回放=自动全程浏览、标定=标定面板 880px+曲线,2D 图像整体缩小);传感器选择(传感器 / 传感器配置 两个带 tooltip 的下拉 + 产品徽标)移到状态轨最左侧,记录模式移入设置区;新增 RecordStatusChip(记录中时长、丢帧、写入异常)与「同一时刻只有一个实心按钮」(新增 dangerOutline 描边危险样式);三轴读数移到曲线上方(16pt 等宽)、只保留最下方时间轴、网格淡化、全程浏览条可拖动;三轴配色统一为蓝/橙/绿并把语义色对比度审计扩展到 toolbar_bg;箭头页脚显示放大倍率与有效区域,深度模式才显示色条与范围;左侧面板改名「2D 图像」且固定显示图像宽高比;标定面板加纵向滚动区、九按钮分两行、六列表头 ResizeToContents;PlaybackDialog 补上从未调用的 retranslate;main() 在事件循环结束后 flush 日志并 os._exit,避免设备 SDK 原生线程把进程挂在解释器收尾。 - Why: 评审提出的 7 点:顶部占高与装饰过多、记录状态不突出、配置切换看不懂且藏得太深、切视图应换主界面布局、标定与回放面板文字被裁、关窗后命令行不退出、2D 图像无固定宽高比且名字不符。 - Verification: ui_check.py(430 项,新增 top area / instrument panels / clean shutdown 段,含中英×1280/1440 的轨不挤压检查)、test_ui_smoke.py(57 项,新增视图切换、标定不裁切、2D 宽高比、回放按钮文字、传感器选择器等用例)、drive_shared.py(146 项)、locale_studio.py(261 键齐全、zh==en、prefs 对齐)、reproduce.py、ui_snapshot.py 四张复核图。 - Not in this change: 录制文件格式、CSV/MP4 写入路径与记录通道数据流不变;三维面板仍是占位符;回放对话框(含离线深度重算)本身只修了按钮文字。
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtGui import QImage, QPixmap, QTransform
|
||||
from PyQt5.QtCore import QSize, Qt, pyqtSignal
|
||||
from PyQt5.QtGui import QImage, QPainter, QPixmap, QTransform
|
||||
from PyQt5.QtWidgets import (
|
||||
QComboBox,
|
||||
QFrame,
|
||||
@@ -14,9 +14,23 @@ from PyQt5.QtWidgets import (
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
from orisys_studio.ui.fit_text import ShrinkableLabel
|
||||
|
||||
VIEW_MODES = ("arrows_pure", "arrows_stitched", "depth")
|
||||
_ROTATION_STEPS = (0, 90, 180, 270)
|
||||
_COLORBAR_WIDTH = 120
|
||||
_COLORBAR_HEIGHT = 10
|
||||
#: Sensor output is 1920x1080; used until the first frame states its own shape.
|
||||
DEFAULT_FRAME_ASPECT = 16.0 / 9.0
|
||||
_MIN_ASPECT = 0.2
|
||||
_MAX_ASPECT = 5.0
|
||||
#: Header + footer + margins, i.e. what the image must leave room for.
|
||||
_IMAGE_CHROME_HEIGHT = 96
|
||||
#: Smallest usable image area. Kept as a constant because ``setFixedHeight``
|
||||
#: overwrites ``minimumHeight``, so the floor cannot be read back from the label.
|
||||
_IMAGE_MIN_HEIGHT = 120
|
||||
|
||||
|
||||
def _normalize_rotation_deg(value: object, default: int = 90) -> int:
|
||||
@@ -29,50 +43,147 @@ def _normalize_rotation_deg(value: object, default: int = 90) -> int:
|
||||
return deg
|
||||
|
||||
|
||||
class ArrowPanel(QFrame):
|
||||
view_mode_changed = pyqtSignal(str)
|
||||
class AspectImageLabel(QLabel):
|
||||
"""Image area whose aspect ratio follows the frame it displays.
|
||||
|
||||
A free-growing label turned a 16:9 frame into a tall letterbox with wide
|
||||
empty bands. Here the height is derived from the width and the frame's own
|
||||
aspect, so the 2D view always has the shape of the picture it shows.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._aspect = DEFAULT_FRAME_ASPECT
|
||||
policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
policy.setHeightForWidth(True)
|
||||
self.setSizePolicy(policy)
|
||||
self.setMinimumSize(200, _IMAGE_MIN_HEIGHT)
|
||||
|
||||
def aspect(self) -> float:
|
||||
return self._aspect
|
||||
|
||||
def set_aspect(self, aspect: float) -> None:
|
||||
try:
|
||||
value = float(aspect)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if not (_MIN_ASPECT <= value <= _MAX_ASPECT):
|
||||
return
|
||||
if abs(value - self._aspect) < 1e-6:
|
||||
return
|
||||
self._aspect = value
|
||||
self.updateGeometry()
|
||||
|
||||
def hasHeightForWidth(self) -> bool: # noqa: N802 - Qt naming
|
||||
return True
|
||||
|
||||
def heightForWidth(self, width: int) -> int: # noqa: N802 - Qt naming
|
||||
return max(1, int(round(int(width) / self._aspect)))
|
||||
|
||||
|
||||
class ColorBarLabel(QLabel):
|
||||
"""The depth view's colour strip, painted from the theme's flow ramp.
|
||||
|
||||
It is generated from the *same* lookup table ``depth_map_to_bgr`` uses, so
|
||||
the legend can never disagree with the picture it explains.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||
super().__init__(parent)
|
||||
self._theme = str(theme)
|
||||
self.setObjectName("colorBar")
|
||||
self.setFixedHeight(_COLORBAR_HEIGHT)
|
||||
self.setMinimumWidth(_COLORBAR_WIDTH)
|
||||
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
self.setVisible(False)
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = str(theme)
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event) -> None: # noqa: N802 - Qt naming
|
||||
from orisys_app_shared.acquisition.arrows import flow_colormap
|
||||
|
||||
# (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)
|
||||
painter = QPainter(self)
|
||||
painter.drawImage(self.rect(), image)
|
||||
painter.end()
|
||||
|
||||
|
||||
class ArrowPanel(QFrame):
|
||||
view_mode_changed = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("contentPanel")
|
||||
self._theme = str(theme)
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
header = QHBoxLayout()
|
||||
self.title = QLabel()
|
||||
self.title.setObjectName("panelTitle")
|
||||
self.fps_label = QLabel()
|
||||
self.fps_label.setObjectName("panelMeta")
|
||||
self.fps_label.setVisible(False)
|
||||
# Shows the fixed depth colour-scale range, so two frames can be
|
||||
# compared instead of the scale silently re-fitting every frame.
|
||||
self.depth_scale_label = QLabel()
|
||||
self.depth_scale_label.setObjectName("panelMeta")
|
||||
self.depth_scale_label.setVisible(False)
|
||||
self._fps_visible = False
|
||||
self._fps_value: float | None = None
|
||||
self._rotation_deg = 90
|
||||
self._rotation_enabled = True
|
||||
self._source_image: QImage | None = None
|
||||
self._depth_scale_text = ""
|
||||
self._arrow_scale = 2.0
|
||||
self._valid_area = False
|
||||
self.view_combo = QComboBox()
|
||||
self.view_combo.setMinimumWidth(140)
|
||||
self.view_combo.currentIndexChanged.connect(self._on_view_index_changed)
|
||||
header.addWidget(self.title)
|
||||
header.addStretch(1)
|
||||
header.addWidget(self.depth_scale_label)
|
||||
header.addWidget(self.fps_label)
|
||||
header.addWidget(self.view_combo)
|
||||
layout.addLayout(header)
|
||||
|
||||
self.image_label = QLabel()
|
||||
self.image_label = AspectImageLabel()
|
||||
self.image_label.setAlignment(Qt.AlignCenter)
|
||||
self.image_label.setMinimumSize(240, 180)
|
||||
self.image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.image_label.setScaledContents(False)
|
||||
self.image_label.setWordWrap(True)
|
||||
layout.addWidget(self.image_label, 1)
|
||||
# The image keeps the frame's shape and is centred in whatever height is
|
||||
# left, instead of stretching to fill a panel with a different aspect.
|
||||
layout.addStretch(1)
|
||||
layout.addWidget(self.image_label)
|
||||
layout.addStretch(1)
|
||||
|
||||
layout.addLayout(self._build_footer())
|
||||
|
||||
self._placeholder = ""
|
||||
self._populate_view_combo()
|
||||
self.retranslate()
|
||||
self._refresh_footer_visibility()
|
||||
self._sync_aspect()
|
||||
|
||||
def _build_footer(self) -> QHBoxLayout:
|
||||
"""What the current view means, in one line under the canvas."""
|
||||
footer = QHBoxLayout()
|
||||
footer.setSpacing(10)
|
||||
|
||||
def annotation() -> QLabel:
|
||||
label = ShrinkableLabel()
|
||||
label.setObjectName("hintText")
|
||||
return label
|
||||
|
||||
self.scale_label = annotation()
|
||||
self.pipeline_label = annotation()
|
||||
self.area_label = annotation()
|
||||
# Shows the fixed depth colour-scale range, so two frames can be
|
||||
# compared instead of the scale silently re-fitting every frame.
|
||||
self.depth_scale_label = QLabel()
|
||||
self.depth_scale_label.setObjectName("panelMeta")
|
||||
self.depth_scale_label.setVisible(False)
|
||||
self.colorbar = ColorBarLabel(theme=self._theme)
|
||||
|
||||
footer.addWidget(self.scale_label)
|
||||
footer.addWidget(self.pipeline_label)
|
||||
footer.addWidget(self.area_label)
|
||||
footer.addStretch(1)
|
||||
footer.addWidget(self.colorbar)
|
||||
footer.addWidget(self.depth_scale_label)
|
||||
return footer
|
||||
|
||||
def view_mode(self) -> str:
|
||||
data = self.view_combo.currentData()
|
||||
@@ -88,12 +199,14 @@ class ArrowPanel(QFrame):
|
||||
self.view_combo.blockSignals(True)
|
||||
self.view_combo.setCurrentIndex(idx)
|
||||
self.view_combo.blockSignals(False)
|
||||
self._refresh_footer_visibility()
|
||||
|
||||
def rotation_deg(self) -> int:
|
||||
return self._rotation_deg
|
||||
|
||||
def set_rotation_deg(self, deg: object) -> None:
|
||||
self._rotation_deg = _normalize_rotation_deg(deg)
|
||||
self._sync_aspect()
|
||||
self._repaint_source()
|
||||
|
||||
def set_rotation_enabled(self, enabled: bool) -> None:
|
||||
@@ -101,39 +214,79 @@ class ArrowPanel(QFrame):
|
||||
if wanted == self._rotation_enabled:
|
||||
return
|
||||
self._rotation_enabled = wanted
|
||||
self._sync_aspect()
|
||||
self._repaint_source()
|
||||
|
||||
def _sync_aspect(self) -> None:
|
||||
"""Re-derive the image area's shape from the frame (or from the default).
|
||||
|
||||
Rotating the picture swaps which side is long, so the area has to follow
|
||||
the rotation as well as the frame.
|
||||
"""
|
||||
image = self._source_image
|
||||
if image is not None and not image.isNull():
|
||||
self._set_display_aspect(image.width(), image.height())
|
||||
return
|
||||
self.image_label.set_aspect(DEFAULT_FRAME_ASPECT)
|
||||
if self._effective_rotation_deg() % 180:
|
||||
self.image_label.set_aspect(1.0 / DEFAULT_FRAME_ASPECT)
|
||||
self._sync_image_height()
|
||||
|
||||
def _set_display_aspect(self, frame_w: int, frame_h: int) -> None:
|
||||
if frame_w <= 0 or frame_h <= 0:
|
||||
return
|
||||
aspect = frame_w / frame_h
|
||||
if self._effective_rotation_deg() % 180:
|
||||
aspect = 1.0 / aspect
|
||||
self.image_label.set_aspect(aspect)
|
||||
self._sync_image_height()
|
||||
|
||||
def rotate_90_ccw(self) -> int:
|
||||
if not self._rotation_enabled:
|
||||
return self._rotation_deg
|
||||
self.set_rotation_deg(self._rotation_deg + 90)
|
||||
return self._rotation_deg
|
||||
|
||||
def set_fps_visible(self, visible: bool) -> None:
|
||||
self._fps_visible = bool(visible)
|
||||
self._refresh_fps_label()
|
||||
def set_arrow_scale(self, factor: float) -> None:
|
||||
"""Show how much the drawn arrows are magnified relative to displacement."""
|
||||
try:
|
||||
self._arrow_scale = float(factor)
|
||||
except (TypeError, ValueError):
|
||||
self._arrow_scale = 1.0
|
||||
self._refresh_scale_label()
|
||||
|
||||
def set_fps(self, fps: float | None) -> None:
|
||||
self._fps_value = None if fps is None else float(fps)
|
||||
self._refresh_fps_label()
|
||||
def set_valid_area(self, present: bool) -> None:
|
||||
"""Say whether the flow grid is masked to the configured valid area."""
|
||||
self._valid_area = bool(present)
|
||||
self.area_label.setText(
|
||||
t("panels.arrow_area_masked") if self._valid_area else t("panels.arrow_area_full")
|
||||
)
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = str(theme)
|
||||
self.colorbar.set_theme(self._theme)
|
||||
|
||||
def set_depth_scale(self, text: str | None) -> None:
|
||||
"""Show the fixed depth colour-scale range (``None`` hides it)."""
|
||||
if not text:
|
||||
self.depth_scale_label.clear()
|
||||
self.depth_scale_label.setVisible(False)
|
||||
return
|
||||
self.depth_scale_label.setText(str(text))
|
||||
self._depth_scale_text = str(text or "")
|
||||
self.depth_scale_label.setText(self._depth_scale_text)
|
||||
self.depth_scale_label.setToolTip(t("panels.depth_scale_tip"))
|
||||
self.depth_scale_label.setVisible(True)
|
||||
self._refresh_footer_visibility()
|
||||
|
||||
def _refresh_fps_label(self) -> None:
|
||||
if not self._fps_visible or self._fps_value is None:
|
||||
self.fps_label.clear()
|
||||
self.fps_label.setVisible(False)
|
||||
return
|
||||
self.fps_label.setText(f"{self._fps_value:.1f} FPS")
|
||||
self.fps_label.setVisible(True)
|
||||
def _refresh_scale_label(self) -> None:
|
||||
self.scale_label.setText(
|
||||
t("panels.arrow_scale", factor=f"{self._arrow_scale:g}")
|
||||
)
|
||||
self.scale_label.setToolTip(t("panels.arrow_scale_tip"))
|
||||
|
||||
def _refresh_footer_visibility(self) -> None:
|
||||
"""Arrow annotations and the depth legend are mutually exclusive."""
|
||||
depth = self.view_mode() == "depth"
|
||||
self.scale_label.setVisible(not depth)
|
||||
self.pipeline_label.setVisible(not depth)
|
||||
self.area_label.setVisible(not depth)
|
||||
self.colorbar.setVisible(depth)
|
||||
self.depth_scale_label.setVisible(depth and bool(self._depth_scale_text))
|
||||
|
||||
def _populate_view_combo(self) -> None:
|
||||
current = self.view_combo.currentData()
|
||||
@@ -147,6 +300,7 @@ class ArrowPanel(QFrame):
|
||||
self.view_combo.blockSignals(False)
|
||||
|
||||
def _on_view_index_changed(self, _index: int) -> None:
|
||||
self._refresh_footer_visibility()
|
||||
self.view_mode_changed.emit(self.view_mode())
|
||||
|
||||
def retranslate(self) -> None:
|
||||
@@ -161,6 +315,10 @@ class ArrowPanel(QFrame):
|
||||
self.view_combo.setItemText(i, label)
|
||||
self.view_combo.setToolTip(t("panels.view_mode_tip"))
|
||||
self.image_label.setToolTip(t("panels.arrow_empty_tip"))
|
||||
self.pipeline_label.setText(t("panels.arrow_pipeline"))
|
||||
self.pipeline_label.setToolTip(t("panels.arrow_pipeline_tip"))
|
||||
self._refresh_scale_label()
|
||||
self.set_valid_area(self._valid_area)
|
||||
if not self.image_label.pixmap() or self.image_label.pixmap().isNull():
|
||||
self._placeholder = t("panels.arrow_empty")
|
||||
self.image_label.setText(self._placeholder)
|
||||
@@ -170,6 +328,24 @@ class ArrowPanel(QFrame):
|
||||
return 0
|
||||
return self._rotation_deg
|
||||
|
||||
def resizeEvent(self, event) -> None: # noqa: N802 - Qt naming
|
||||
super().resizeEvent(event)
|
||||
self._sync_image_height()
|
||||
|
||||
def _sync_image_height(self) -> None:
|
||||
"""Give the image area the height its own aspect ratio asks for.
|
||||
|
||||
A horizontal splitter hands its children the full available height and
|
||||
never asks them for ``heightForWidth``, so the aspect ratio has to be
|
||||
applied here or the 16:9 frame stretches into a tall letterbox.
|
||||
"""
|
||||
label = self.image_label
|
||||
wanted = label.heightForWidth(max(1, label.width()))
|
||||
room = max(_IMAGE_MIN_HEIGHT, self.height() - _IMAGE_CHROME_HEIGHT)
|
||||
height = max(_IMAGE_MIN_HEIGHT, min(wanted, room))
|
||||
if label.maximumHeight() != height:
|
||||
label.setFixedHeight(height)
|
||||
|
||||
def _repaint_source(self) -> None:
|
||||
if self._source_image is None or self._source_image.isNull():
|
||||
return
|
||||
@@ -177,6 +353,8 @@ class ArrowPanel(QFrame):
|
||||
|
||||
def _paint_image(self, image: QImage) -> None:
|
||||
pix = QPixmap.fromImage(image)
|
||||
# The area takes the shape of the *displayed* picture.
|
||||
self._set_display_aspect(image.width(), image.height())
|
||||
deg = self._effective_rotation_deg()
|
||||
if deg:
|
||||
# QTransform.rotate is clockwise on screen pixmaps; negate for CCW.
|
||||
@@ -202,4 +380,3 @@ class ArrowPanel(QFrame):
|
||||
text = message if message is not None else t("panels.arrow_empty")
|
||||
self._placeholder = text
|
||||
self.image_label.setText(text)
|
||||
self.set_fps(None)
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"""Brand bar shown at the top of the studio shell.
|
||||
|
||||
A slim deep-navy rail carrying the corporate logo lockup and the product name.
|
||||
Logo colours are re-tinted at runtime so a single asset works on the brand
|
||||
background in both themes; all other colours come from the theme tokens.
|
||||
A slim deep-navy rail carrying the corporate logo lockup, the product name and
|
||||
the three top-level views (live / replay / calibration). Logo colours are
|
||||
re-tinted at runtime so a single asset works on the brand background in both
|
||||
themes; all other colours come from the theme tokens.
|
||||
|
||||
The rail is deliberately thin (``BAR_HEIGHT``): the instrument's own state lives
|
||||
in the status rail below it, not here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -10,9 +14,9 @@ from __future__ import annotations
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtGui import QColor, QPainter, QPixmap
|
||||
from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout
|
||||
from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import logo_topbar_path
|
||||
@@ -20,9 +24,9 @@ from orisys_app_shared.theme import color
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LOGO_SIZE = 22
|
||||
BAR_HEIGHT = 40
|
||||
DIVIDER_HEIGHT = 18
|
||||
LOGO_SIZE = 16
|
||||
BAR_HEIGHT = 28
|
||||
DIVIDER_HEIGHT = 14
|
||||
|
||||
|
||||
def tinted_pixmap(path: Path | str, tint: str, size: int = LOGO_SIZE) -> QPixmap:
|
||||
@@ -48,7 +52,14 @@ def tinted_pixmap(path: Path | str, tint: str, size: int = LOGO_SIZE) -> QPixmap
|
||||
|
||||
|
||||
class BrandBar(QFrame):
|
||||
"""Thin brand rail: logo lockup on the left, product name on the right."""
|
||||
"""Thin brand rail: lockup and top-level view switcher, nothing else."""
|
||||
|
||||
#: The user asked for the live view.
|
||||
live_requested = pyqtSignal()
|
||||
#: The user asked for the history/replay view.
|
||||
playback_requested = pyqtSignal()
|
||||
#: The user asked for the calibration view.
|
||||
calibration_requested = pyqtSignal()
|
||||
|
||||
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||
super().__init__(parent)
|
||||
@@ -57,8 +68,8 @@ class BrandBar(QFrame):
|
||||
self.setFixedHeight(BAR_HEIGHT)
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(14, 0, 14, 0)
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(10, 0, 10, 0)
|
||||
layout.setSpacing(8)
|
||||
|
||||
self.logo = QLabel()
|
||||
self.logo.setObjectName("brandLogo")
|
||||
@@ -66,6 +77,8 @@ class BrandBar(QFrame):
|
||||
self.logo.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(self.logo)
|
||||
|
||||
# The lockup keeps its two labels (the product's tagline stays available
|
||||
# as a tooltip), but the rail is a single line now.
|
||||
lockup = QVBoxLayout()
|
||||
lockup.setContentsMargins(0, 0, 0, 0)
|
||||
lockup.setSpacing(0)
|
||||
@@ -73,10 +86,24 @@ class BrandBar(QFrame):
|
||||
self.name.setObjectName("brandName")
|
||||
self.tagline = QLabel()
|
||||
self.tagline.setObjectName("brandTagline")
|
||||
self.tagline.setVisible(False)
|
||||
lockup.addWidget(self.name)
|
||||
lockup.addWidget(self.tagline)
|
||||
layout.addLayout(lockup)
|
||||
|
||||
self.btn_live = self._nav_button()
|
||||
self.btn_playback = self._nav_button()
|
||||
self.btn_calibration = self._nav_button()
|
||||
self.btn_live.clicked.connect(lambda _checked=False: self.live_requested.emit())
|
||||
self.btn_playback.clicked.connect(lambda _checked=False: self.playback_requested.emit())
|
||||
self.btn_calibration.clicked.connect(
|
||||
lambda _checked=False: self.calibration_requested.emit()
|
||||
)
|
||||
layout.addSpacing(4)
|
||||
layout.addWidget(self.btn_live)
|
||||
layout.addWidget(self.btn_playback)
|
||||
layout.addWidget(self.btn_calibration)
|
||||
|
||||
layout.addStretch(1)
|
||||
|
||||
self.divider = QFrame()
|
||||
@@ -91,6 +118,15 @@ class BrandBar(QFrame):
|
||||
|
||||
self.set_theme(theme)
|
||||
self.retranslate()
|
||||
self.set_active_view("live")
|
||||
|
||||
def _nav_button(self) -> QPushButton:
|
||||
button = QPushButton()
|
||||
button.setObjectName("navButton")
|
||||
button.setCursor(Qt.PointingHandCursor)
|
||||
button.setFlat(True)
|
||||
button.setCheckable(True)
|
||||
return button
|
||||
|
||||
def _refresh_logo(self) -> None:
|
||||
pixmap = tinted_pixmap(
|
||||
@@ -104,6 +140,24 @@ class BrandBar(QFrame):
|
||||
self.logo.setPixmap(pixmap)
|
||||
self.logo.show()
|
||||
|
||||
def set_active_view(self, view: str | None) -> None:
|
||||
"""Mark which top-level view is on screen.
|
||||
|
||||
``None`` leaves all three unmarked: a hand-arranged layout is not one of
|
||||
the three views, and pretending otherwise would misreport the state.
|
||||
"""
|
||||
active = str(view or "")
|
||||
for button, name in (
|
||||
(self.btn_live, "live"),
|
||||
(self.btn_playback, "replay"),
|
||||
(self.btn_calibration, "calibration"),
|
||||
):
|
||||
is_active = name == active
|
||||
button.setChecked(is_active)
|
||||
button.setProperty("navActive", "true" if is_active else "false")
|
||||
button.style().unpolish(button)
|
||||
button.style().polish(button)
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = str(theme)
|
||||
self._refresh_logo()
|
||||
@@ -112,4 +166,10 @@ class BrandBar(QFrame):
|
||||
self.name.setText(t("brand.name"))
|
||||
self.tagline.setText(t("brand.tagline"))
|
||||
self.product.setText(t("app.title"))
|
||||
self.name.setToolTip(t("app.title"))
|
||||
self.name.setToolTip(f"{t('app.title')} — {t('brand.tagline')}")
|
||||
self.btn_live.setText(t("brand.nav_live"))
|
||||
self.btn_live.setToolTip(t("brand.nav_live_tip"))
|
||||
self.btn_playback.setText(t("brand.nav_playback"))
|
||||
self.btn_playback.setToolTip(t("brand.nav_playback_tip"))
|
||||
self.btn_calibration.setText(t("brand.nav_calibration"))
|
||||
self.btn_calibration.setToolTip(t("brand.nav_calibration_tip"))
|
||||
|
||||
@@ -20,10 +20,12 @@ from PyQt5.QtWidgets import (
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.force.calibration_io import (
|
||||
@@ -47,7 +49,6 @@ CHANNEL_LABELS = ("fn", "fx", "fy")
|
||||
#: Diagnostic columns, in display order: reference load, the three axis means,
|
||||
#: a stability measure and the point's state.
|
||||
DIAGNOSTIC_COLUMNS = ("load", "fn", "fx", "fy", "stability", "state")
|
||||
DIAGNOSTIC_COMPACT_COLUMNS = (0, 4, 5)
|
||||
|
||||
|
||||
def _mono_font() -> str:
|
||||
@@ -88,7 +89,23 @@ class CalPanel(QFrame):
|
||||
self._mono = _mono_font()
|
||||
self._mono_font = QFont(self._mono, 10)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
# Everything below the title scrolls: this panel has more content than
|
||||
# a short window can show (a six-column table, the fit summary, the SN
|
||||
# field and nine actions), and a clipped button is a lost function.
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
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)
|
||||
content = QWidget()
|
||||
self.scroll.setWidget(content)
|
||||
outer.addWidget(self.scroll)
|
||||
|
||||
root = QVBoxLayout(content)
|
||||
root.setContentsMargins(9, 9, 9, 9)
|
||||
root.setSpacing(8)
|
||||
|
||||
title_row = QHBoxLayout()
|
||||
@@ -171,11 +188,14 @@ class CalPanel(QFrame):
|
||||
self.table.setAlternatingRowColors(True)
|
||||
self.table.setShowGrid(False)
|
||||
header = self.table.horizontalHeader()
|
||||
header.setSectionResizeMode(QHeaderView.Stretch)
|
||||
for column in DIAGNOSTIC_COMPACT_COLUMNS:
|
||||
header.setSectionResizeMode(column, QHeaderView.ResizeToContents)
|
||||
# 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.setMinimumSectionSize(64)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.verticalHeader().setDefaultSectionSize(26)
|
||||
self.table.setMinimumHeight(150)
|
||||
self.table.itemSelectionChanged.connect(self._on_row_selected)
|
||||
self.table.doubleClicked.connect(self._on_row_activated)
|
||||
root.addWidget(self.table, 1)
|
||||
@@ -194,8 +214,6 @@ class CalPanel(QFrame):
|
||||
sn_row.addWidget(self.sn_edit, 1)
|
||||
root.addLayout(sn_row)
|
||||
|
||||
actions = QHBoxLayout()
|
||||
actions.setSpacing(6)
|
||||
self.btn_delete = QPushButton()
|
||||
self.btn_clear_channel = QPushButton()
|
||||
self.btn_clear_all = QPushButton()
|
||||
@@ -227,17 +245,26 @@ class CalPanel(QFrame):
|
||||
self.btn_save_record.clicked.connect(self._on_save_record)
|
||||
self.btn_export.clicked.connect(self._on_export)
|
||||
self.btn_apply.clicked.connect(self._on_apply)
|
||||
actions.addWidget(self.btn_toggle_excluded)
|
||||
actions.addWidget(self.btn_toggle_validation)
|
||||
actions.addWidget(self.btn_delete)
|
||||
actions.addWidget(self.btn_clear_channel)
|
||||
actions.addWidget(self.btn_clear_all)
|
||||
actions.addStretch(1)
|
||||
actions.addWidget(self.btn_open_record)
|
||||
actions.addWidget(self.btn_save_record)
|
||||
actions.addWidget(self.btn_export)
|
||||
actions.addWidget(self.btn_apply)
|
||||
root.addLayout(actions)
|
||||
# Two rows, not nine buttons in a row: in one line every label was
|
||||
# squeezed until it was unreadable ("排 除 / 设 为 验 证 点").
|
||||
selection_actions = QHBoxLayout()
|
||||
selection_actions.setSpacing(6)
|
||||
selection_actions.addWidget(self.btn_toggle_excluded)
|
||||
selection_actions.addWidget(self.btn_toggle_validation)
|
||||
selection_actions.addWidget(self.btn_delete)
|
||||
selection_actions.addWidget(self.btn_clear_channel)
|
||||
selection_actions.addWidget(self.btn_clear_all)
|
||||
selection_actions.addStretch(1)
|
||||
root.addLayout(selection_actions)
|
||||
|
||||
file_actions = QHBoxLayout()
|
||||
file_actions.setSpacing(6)
|
||||
file_actions.addStretch(1)
|
||||
file_actions.addWidget(self.btn_open_record)
|
||||
file_actions.addWidget(self.btn_save_record)
|
||||
file_actions.addWidget(self.btn_export)
|
||||
file_actions.addWidget(self.btn_apply)
|
||||
root.addLayout(file_actions)
|
||||
|
||||
self._refresh_enabled()
|
||||
self._refresh_table()
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Labels that may be squeezed below their text width.
|
||||
|
||||
A ``QLabel`` reports its text width as a *minimum*, so one long annotation can
|
||||
decide the minimum width of a whole panel (or of the top rail) and push its
|
||||
neighbours out of view. These labels keep their size hint — they ask for the
|
||||
space their text needs — but allow the layout to shrink them, at which point Qt
|
||||
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
|
||||
|
||||
|
||||
class ShrinkableLabel(QLabel):
|
||||
"""A label whose minimum width is 0 (the text is clipped when squeezed)."""
|
||||
|
||||
def __init__(self, text: str = "", parent=None) -> None:
|
||||
super().__init__(text, parent)
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
|
||||
self.setMinimumWidth(0)
|
||||
|
||||
def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt naming
|
||||
hint = super().minimumSizeHint()
|
||||
return QSize(0, hint.height())
|
||||
@@ -6,7 +6,7 @@ import time
|
||||
|
||||
import numpy as np
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtGui import QColor, QFont, QFontDatabase
|
||||
from PyQt5.QtGui import QColor, QFont, QFontDatabase, QFontMetrics
|
||||
from PyQt5.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
@@ -34,6 +34,22 @@ CHANNEL_LABELS = ("FN", "FX", "FY")
|
||||
CHANNEL_TOKENS = ("plot_fn", "plot_fx", "plot_fy")
|
||||
#: How often the whole-session overview strip is rebuilt while following live.
|
||||
OVERVIEW_REFRESH_S = 1.0
|
||||
#: Grid opacity. The curves are the data; a full-strength grid competed with
|
||||
#: them on a dark background.
|
||||
GRID_ALPHA = 0.10
|
||||
OVERVIEW_GRID_ALPHA = 0.08
|
||||
#: Opacity of the draggable window marker over the overview strip.
|
||||
WINDOW_BRUSH_ALPHA = 70
|
||||
#: Point size of the axis readings. They are the number an operator reads from
|
||||
#: across the bench, so they are not the body-text size.
|
||||
VALUE_FONT_PT = 16
|
||||
|
||||
|
||||
def _window_brush(theme: str):
|
||||
"""Translucent accent wash for the overview's draggable window."""
|
||||
accent = QColor(color(theme, "accent"))
|
||||
accent.setAlpha(WINDOW_BRUSH_ALPHA)
|
||||
return accent
|
||||
|
||||
|
||||
def _nice_ticks(lo: float, hi: float, count: int = 5) -> list[tuple[float, str]]:
|
||||
@@ -155,6 +171,29 @@ class ForcePlotsPanel(QFrame):
|
||||
header.addWidget(self.unit_badge)
|
||||
root.addLayout(header)
|
||||
|
||||
# The three axis readouts sit *above* the curves: they are what an
|
||||
# operator reads at a glance, and the numbers must not be the last thing
|
||||
# under a stack of plots.
|
||||
self.value_group = QFrame()
|
||||
self.value_group.setObjectName("valueGroup")
|
||||
values_row = QHBoxLayout(self.value_group)
|
||||
values_row.setContentsMargins(2, 0, 2, 0)
|
||||
values_row.setSpacing(14)
|
||||
self.value_cards: list[QFrame] = []
|
||||
self.value_labels: list[QLabel] = []
|
||||
self.channel_badges: list[QLabel] = []
|
||||
self.channel_names: list[QLabel] = []
|
||||
self.value_units: list[QLabel] = []
|
||||
for label in CHANNEL_LABELS:
|
||||
card, badge, name_label, value_label, unit_label = self._build_value_card(label)
|
||||
values_row.addWidget(card, 1)
|
||||
self.value_cards.append(card)
|
||||
self.channel_badges.append(badge)
|
||||
self.channel_names.append(name_label)
|
||||
self.value_labels.append(value_label)
|
||||
self.value_units.append(unit_label)
|
||||
root.addWidget(self.value_group)
|
||||
|
||||
self.plot_frame = QFrame()
|
||||
self.plot_frame.setObjectName("cardPanel")
|
||||
plot_layout = QVBoxLayout(self.plot_frame)
|
||||
@@ -170,14 +209,17 @@ class ForcePlotsPanel(QFrame):
|
||||
pen = pg.mkPen(color(theme, CHANNEL_TOKENS[0]), width=2)
|
||||
for i, _name in enumerate(CHANNEL_LABELS):
|
||||
plot = self.plot_widget.addPlot(row=i, col=0)
|
||||
plot.showGrid(x=True, y=True, alpha=0.25)
|
||||
plot.showGrid(x=True, y=True, alpha=GRID_ALPHA)
|
||||
plot.setMouseEnabled(x=False, y=False)
|
||||
if i > 0:
|
||||
plot.setXLink(self.plots[0])
|
||||
curve = plot.plot(pen=pen)
|
||||
# One shared time cursor across all three channels.
|
||||
cursor = pg.InfiniteLine(
|
||||
pos=0.0, angle=90, movable=False, pen=pg.mkPen("#888888", width=1)
|
||||
pos=0.0,
|
||||
angle=90,
|
||||
movable=False,
|
||||
pen=pg.mkPen(color(theme, "text_muted"), width=1),
|
||||
)
|
||||
plot.addItem(cursor, ignoreBounds=True)
|
||||
plot.getViewBox().sigXRangeChanged.connect(self._on_x_range_changed)
|
||||
@@ -188,80 +230,99 @@ class ForcePlotsPanel(QFrame):
|
||||
# Whole-session overview: the full envelope plus the current window.
|
||||
self.overview = self.plot_widget.addPlot(row=len(CHANNEL_LABELS), col=0)
|
||||
self.overview.setMouseEnabled(x=False, y=False)
|
||||
self.overview.showGrid(x=True, y=False, alpha=0.2)
|
||||
self.overview.showGrid(x=True, y=False, alpha=OVERVIEW_GRID_ALPHA)
|
||||
self.overview.setMaximumHeight(46)
|
||||
self.overview.getAxis("left").setStyle(showValues=False)
|
||||
self.overview_curve = self.overview.plot(pen=pg.mkPen("#8A93A6", width=1))
|
||||
self.overview_curve = self.overview.plot(
|
||||
pen=pg.mkPen(color(theme, "text_muted"), width=1)
|
||||
)
|
||||
# Dragging the region *is* the timeline control: it moves the visible
|
||||
# window instead of a separate slider the charts would have to follow.
|
||||
self.overview_region = pg.LinearRegionItem(
|
||||
values=(0.0, 1.0), movable=False, brush=(90, 110, 190, 60)
|
||||
values=(0.0, 1.0), movable=True, brush=_window_brush(theme)
|
||||
)
|
||||
self.overview_region.setZValue(-10)
|
||||
self.overview_region.sigRegionChanged.connect(self._on_overview_region_changed)
|
||||
self.overview.addItem(self.overview_region)
|
||||
self.overview_cursor = pg.InfiniteLine(
|
||||
pos=0.0, angle=90, movable=False, pen=pg.mkPen("#888888", width=1)
|
||||
pos=0.0,
|
||||
angle=90,
|
||||
movable=False,
|
||||
pen=pg.mkPen(color(theme, "text_muted"), width=1),
|
||||
)
|
||||
self.overview.addItem(self.overview_cursor, ignoreBounds=True)
|
||||
for row in range(len(CHANNEL_LABELS)):
|
||||
self.plot_widget.ci.layout.setRowStretchFactor(row, 3)
|
||||
self.plot_widget.ci.layout.setRowStretchFactor(len(CHANNEL_LABELS), 1)
|
||||
|
||||
# The overview strip's own caption and readout, plus the selection text.
|
||||
browse_row = QHBoxLayout()
|
||||
browse_row.setSpacing(8)
|
||||
self.window_caption = QLabel()
|
||||
self.window_caption.setObjectName("panelTitle")
|
||||
self.window_label = QLabel()
|
||||
self.window_label.setObjectName("panelMeta")
|
||||
self.stats_label = QLabel()
|
||||
self.stats_label.setObjectName("panelMeta")
|
||||
self.stats_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
root.addWidget(self.stats_label)
|
||||
|
||||
values_row = QHBoxLayout()
|
||||
values_row.setSpacing(6)
|
||||
self.value_cards: list[QFrame] = []
|
||||
self.value_labels: list[QLabel] = []
|
||||
self.channel_badges: list[QLabel] = []
|
||||
self.value_units: list[QLabel] = []
|
||||
for label in CHANNEL_LABELS:
|
||||
card, badge, value_label, unit_label = self._build_value_card(label)
|
||||
values_row.addWidget(card, 1)
|
||||
self.value_cards.append(card)
|
||||
self.channel_badges.append(badge)
|
||||
self.value_labels.append(value_label)
|
||||
self.value_units.append(unit_label)
|
||||
root.addLayout(values_row)
|
||||
browse_row.addWidget(self.window_caption)
|
||||
browse_row.addWidget(self.window_label)
|
||||
browse_row.addStretch(1)
|
||||
browse_row.addWidget(self.stats_label)
|
||||
root.addLayout(browse_row)
|
||||
|
||||
self.set_theme(theme)
|
||||
self._interactive(False)
|
||||
self._apply_axis_mode()
|
||||
self.retranslate()
|
||||
|
||||
def _build_value_card(self, channel: str) -> tuple[QFrame, QLabel, QLabel, QLabel]:
|
||||
def _build_value_card(
|
||||
self, channel: str
|
||||
) -> tuple[QFrame, QLabel, QLabel, QLabel, QLabel]:
|
||||
"""One axis readout: channel badge + name over a large monospace value.
|
||||
|
||||
The value is deliberately borderless and large — three boxed cards side
|
||||
by side were part of what made the panel look fragmented.
|
||||
"""
|
||||
card = QFrame()
|
||||
card.setObjectName("cardPanel")
|
||||
card.setObjectName("valueGroup")
|
||||
layout = QVBoxLayout(card)
|
||||
layout.setContentsMargins(7, 4, 7, 4)
|
||||
layout.setSpacing(1)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
top = QHBoxLayout()
|
||||
top.setSpacing(6)
|
||||
badge = QLabel(channel)
|
||||
badge.setObjectName("unitBadge")
|
||||
top.addWidget(badge)
|
||||
name_label = QLabel()
|
||||
name_label.setObjectName("hintText")
|
||||
top.addWidget(name_label)
|
||||
top.addStretch(1)
|
||||
unit_label = QLabel()
|
||||
unit_label.setObjectName("hintText")
|
||||
top.addWidget(unit_label)
|
||||
layout.addLayout(top)
|
||||
|
||||
value_row = QHBoxLayout()
|
||||
value_row.setSpacing(5)
|
||||
value_label = QLabel()
|
||||
value_label.setObjectName("valueCard")
|
||||
value_label.setFixedHeight(20)
|
||||
value_label.setContentsMargins(6, 0, 6, 0)
|
||||
value_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
value_label.setFont(_mono_font(9, bold=True))
|
||||
|
||||
layout.addLayout(top)
|
||||
layout.addWidget(value_label)
|
||||
return card, badge, value_label, unit_label
|
||||
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.
|
||||
value_label.setMinimumHeight(value_label.fontMetrics().height() + 4)
|
||||
unit_label = QLabel()
|
||||
unit_label.setObjectName("hintText")
|
||||
value_row.addWidget(value_label)
|
||||
value_row.addWidget(unit_label)
|
||||
value_row.addStretch(1)
|
||||
layout.addLayout(value_row)
|
||||
return card, badge, name_label, value_label, unit_label
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = theme
|
||||
bg = color(theme, "plot_bg")
|
||||
text = color(theme, "text_primary")
|
||||
muted = color(theme, "text_muted")
|
||||
self.plot_widget.setBackground(bg)
|
||||
axis_pen = pg.mkPen(text)
|
||||
for i, plot in enumerate(self.plots):
|
||||
@@ -273,9 +334,17 @@ class ForcePlotsPanel(QFrame):
|
||||
axis = plot.getAxis(axis_name)
|
||||
axis.setPen(axis_pen)
|
||||
axis.setTextPen(axis_pen)
|
||||
plot.showGrid(x=True, y=True, alpha=0.25)
|
||||
plot.showGrid(x=True, y=True, alpha=GRID_ALPHA)
|
||||
# One shared time axis: only the bottom plot names the seconds, so
|
||||
# three stacked charts stop repeating the same ruler three times.
|
||||
plot.getAxis("bottom").setStyle(showValues=i == len(self.plots) - 1)
|
||||
# Color each channel axis label to match its curve pen.
|
||||
plot.setLabel("left", CHANNEL_LABELS[i], color=color(theme, CHANNEL_TOKENS[i]))
|
||||
self.overview.showGrid(x=True, y=False, alpha=OVERVIEW_GRID_ALPHA)
|
||||
self.overview_curve.setPen(pg.mkPen(muted, width=1))
|
||||
self.overview_region.setBrush(_window_brush(theme))
|
||||
for cursor in (*self.cursors, self.overview_cursor):
|
||||
cursor.setPen(pg.mkPen(muted, width=1))
|
||||
pens = tuple(color(theme, token) for token in CHANNEL_TOKENS)
|
||||
for curve, pen in zip(self.curves, pens):
|
||||
curve.setPen(pg.mkPen(pen, width=2))
|
||||
@@ -285,10 +354,20 @@ class ForcePlotsPanel(QFrame):
|
||||
f" background-color: {color(theme, 'card_bg')};"
|
||||
f" border: 1px solid {color(theme, 'card_border')}; }}"
|
||||
)
|
||||
# The app stylesheet sets a global widget font, and a matching stylesheet
|
||||
# rule always wins over ``QWidget.setFont``. The readings therefore have
|
||||
# to carry their monospace face *in the stylesheet*, or they silently
|
||||
# fall back to the UI font and lose digit alignment.
|
||||
mono = _mono_font(VALUE_FONT_PT, bold=True)
|
||||
min_height = QFontMetrics(mono).height() + 4
|
||||
for value_label, token in zip(self.value_labels, CHANNEL_TOKENS):
|
||||
value_label.setFont(mono)
|
||||
value_label.setStyleSheet(
|
||||
f"QLabel#valueCard {{ color: {color(theme, token)}; }}"
|
||||
f"QLabel#valueCard {{ color: {color(theme, token)};"
|
||||
f' font-family: "{mono.family()}"; font-size: {VALUE_FONT_PT}pt;'
|
||||
" font-weight: 700; background: transparent; border: none; }"
|
||||
)
|
||||
value_label.setMinimumHeight(min_height)
|
||||
self._render_value_cards()
|
||||
|
||||
def set_window_sec(self, sec: float) -> None:
|
||||
@@ -606,6 +685,7 @@ class ForcePlotsPanel(QFrame):
|
||||
first, last = self._source.extent()
|
||||
if last <= first:
|
||||
self.overview_curve.setData([], [])
|
||||
self._refresh_window_label()
|
||||
return
|
||||
# The overview is a whole-session read, so only rebuild it when it can
|
||||
# have changed (window/unit/session) rather than on every pan.
|
||||
@@ -617,16 +697,56 @@ class ForcePlotsPanel(QFrame):
|
||||
finite = np.isfinite(column)
|
||||
self.overview_curve.setData(window.times[finite], column[finite])
|
||||
self.overview.setXRange(first, last, padding=0.0)
|
||||
if self._mode == "browse":
|
||||
self.overview_region.setRegion(self._window)
|
||||
self.overview_region.show()
|
||||
else:
|
||||
self.overview_region.hide()
|
||||
self._syncing = True
|
||||
try:
|
||||
if self._mode == "browse":
|
||||
self.overview_region.setRegion(self._window)
|
||||
self.overview_region.show()
|
||||
else:
|
||||
self.overview_region.hide()
|
||||
finally:
|
||||
self._syncing = False
|
||||
self.overview.setYRange(
|
||||
float(np.nanmin(column[finite])) if finite.any() else 0.0,
|
||||
float(np.nanmax(column[finite])) if finite.any() else 1.0,
|
||||
padding=0.05,
|
||||
)
|
||||
self._refresh_window_label()
|
||||
|
||||
def _refresh_window_label(self) -> None:
|
||||
"""The overview strip's readout: where the window is inside the session."""
|
||||
first, last = self._source.extent()
|
||||
if last <= first:
|
||||
self.window_label.setText("")
|
||||
return
|
||||
if self._mode == "browse":
|
||||
start, end = self._window
|
||||
else:
|
||||
start, end = 0.0, min(last, self.history.window_sec)
|
||||
self.window_label.setText(
|
||||
t(
|
||||
"chart.window_readout",
|
||||
start=f"{start:.1f}",
|
||||
end=f"{end:.1f}",
|
||||
total=f"{last - first:.1f}",
|
||||
)
|
||||
)
|
||||
|
||||
def _on_overview_region_changed(self) -> None:
|
||||
"""Dragging the overview marker moves the visible window.
|
||||
|
||||
The region is the timeline control, so a drag is a pan: it enters
|
||||
history browsing exactly like dragging the charts themselves.
|
||||
"""
|
||||
if self._syncing or self._store is None:
|
||||
return
|
||||
start, end = (float(v) for v in self.overview_region.getRegion())
|
||||
if not (end > start):
|
||||
return
|
||||
first, last = self._source.extent()
|
||||
if last <= first:
|
||||
return
|
||||
self.set_window(start, end)
|
||||
|
||||
def _overview_points(self) -> int:
|
||||
return 1200
|
||||
@@ -752,11 +872,16 @@ class ForcePlotsPanel(QFrame):
|
||||
self.title.setText(t("panels.plots"))
|
||||
for badge, label in zip(self.channel_badges, CHANNEL_LABELS):
|
||||
badge.setText(label)
|
||||
for name_label, key in zip(self.channel_names, ("fn", "fx", "fy")):
|
||||
name_label.setText(t(f"chart.{key}_name"))
|
||||
self.btn_latest.setText(t("chart.return_latest"))
|
||||
self.btn_latest.setToolTip(t("chart.return_latest_tip"))
|
||||
self.btn_overview.setText(t("chart.overview"))
|
||||
self.btn_overview.setToolTip(t("chart.overview_tip"))
|
||||
self.btn_box_zoom.setText(t("chart.box_zoom"))
|
||||
self.btn_box_zoom.setToolTip(t("chart.box_zoom_tip"))
|
||||
self.window_caption.setText(t("chart.overview"))
|
||||
self.window_caption.setToolTip(t("chart.window_tip"))
|
||||
self._refresh_mode_label()
|
||||
self._refresh_window_label()
|
||||
self._render_value_cards()
|
||||
|
||||
@@ -165,6 +165,9 @@ class PlaybackDialog(QDialog):
|
||||
|
||||
if directory is not None:
|
||||
self.open_recording(Path(directory))
|
||||
# Labels last: without this the three buttons stayed empty, because the
|
||||
# dialog never retranslated after being built.
|
||||
self.retranslate()
|
||||
|
||||
# ------------------------------------------------------------------ opening
|
||||
def _on_open(self) -> None:
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Record status strip: one truthful, always-visible readout of the record channel.
|
||||
|
||||
The strip answers four questions the record button alone cannot:
|
||||
|
||||
* is this session *acquiring* or also *recording* (the phase rail says the first);
|
||||
* for how long has the recording been running;
|
||||
* did the writer drop anything;
|
||||
* did a write fail.
|
||||
|
||||
All of that comes from :class:`~orisys_app_shared.record.experiment.RecordStats`,
|
||||
which the recorder exposes as a locked snapshot, so the UI can read it on its own
|
||||
timer without touching the writer thread.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtWidgets import QLabel
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
#: The recording channel is idle, never started in this session.
|
||||
IDLE = "idle"
|
||||
#: A recording is running; the elapsed time ticks every UI frame.
|
||||
RECORDING = "recording"
|
||||
#: The writer is still closing its files — never present that as finished.
|
||||
CLOSING = "closing"
|
||||
#: A write failed, or frames were rejected after a failure.
|
||||
ERROR = "error"
|
||||
#: A finished recording (of this session) is being summarised.
|
||||
DONE = "done"
|
||||
|
||||
STATES = (IDLE, RECORDING, CLOSING, ERROR, DONE)
|
||||
|
||||
#: Which theme token colours the chip in each state.
|
||||
_STATE_TOKENS = {
|
||||
IDLE: "status_idle",
|
||||
RECORDING: "status_running",
|
||||
CLOSING: "status_connecting",
|
||||
ERROR: "status_error",
|
||||
DONE: "status_idle",
|
||||
}
|
||||
|
||||
_STATE_KEYS = {
|
||||
IDLE: "record.state_idle",
|
||||
RECORDING: "record.state_recording",
|
||||
CLOSING: "record.state_closing",
|
||||
ERROR: "record.state_error",
|
||||
DONE: "record.state_done",
|
||||
}
|
||||
|
||||
|
||||
def format_elapsed(seconds: object) -> str:
|
||||
"""``mm:ss.d`` for a duration in seconds; never negative, never raising."""
|
||||
try:
|
||||
value = max(0.0, float(seconds)) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
value = 0.0
|
||||
minutes, rest = divmod(value, 60.0)
|
||||
return f"{int(minutes):02d}:{rest:04.1f}"
|
||||
|
||||
|
||||
class RecordStatusChip(QLabel):
|
||||
"""Compact record-state chip (text + colour), stable under 60 Hz updates."""
|
||||
|
||||
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||
super().__init__(parent)
|
||||
self._theme = str(theme)
|
||||
self._state = IDLE
|
||||
self._duration_s = 0.0
|
||||
self._frames = 0
|
||||
self._segments = 0
|
||||
self._dropped = 0
|
||||
self._dropped_data = 0
|
||||
self._dropped_video = 0
|
||||
self._dropped_gaps = 0
|
||||
self._errors = 0
|
||||
self._detail = ""
|
||||
self._directory = ""
|
||||
self._token = ""
|
||||
self._tooltip_cache = ""
|
||||
self.setObjectName("recordChip")
|
||||
self._refresh()
|
||||
|
||||
# ------------------------------------------------------------------ API
|
||||
def state(self) -> str:
|
||||
return self._state
|
||||
|
||||
def stats(self) -> dict:
|
||||
"""The raw numbers behind the current text (used by tooltips/tests)."""
|
||||
return {
|
||||
"state": self._state,
|
||||
"duration_s": self._duration_s,
|
||||
"frames": self._frames,
|
||||
"segments": self._segments,
|
||||
"dropped": self._dropped,
|
||||
"dropped_data": self._dropped_data,
|
||||
"dropped_video": self._dropped_video,
|
||||
"dropped_gaps": self._dropped_gaps,
|
||||
"errors": self._errors,
|
||||
"detail": self._detail,
|
||||
"directory": self._directory,
|
||||
}
|
||||
|
||||
def set_state(
|
||||
self,
|
||||
state: str,
|
||||
*,
|
||||
duration_s: float = 0.0,
|
||||
frames: int = 0,
|
||||
segments: int = 0,
|
||||
dropped: int = 0,
|
||||
dropped_data: int = 0,
|
||||
dropped_video: int = 0,
|
||||
dropped_gaps: int = 0,
|
||||
errors: int = 0,
|
||||
detail: str = "",
|
||||
directory: str = "",
|
||||
) -> None:
|
||||
"""Update every number the strip can show, then repaint if it changed."""
|
||||
self._state = state if state in STATES else IDLE
|
||||
self._duration_s = float(duration_s or 0.0)
|
||||
self._frames = int(frames or 0)
|
||||
self._segments = int(segments or 0)
|
||||
self._dropped = int(dropped or 0)
|
||||
self._dropped_data = int(dropped_data or 0)
|
||||
self._dropped_video = int(dropped_video or 0)
|
||||
self._dropped_gaps = int(dropped_gaps or 0)
|
||||
self._errors = int(errors or 0)
|
||||
self._detail = str(detail or "")
|
||||
self._directory = str(directory or "")
|
||||
self._refresh()
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = str(theme)
|
||||
self._refresh()
|
||||
|
||||
def set_directory(self, directory: str) -> None:
|
||||
"""Where the recording is being written (shown in the tooltip)."""
|
||||
self._directory = str(directory or "")
|
||||
self._refresh()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self._refresh()
|
||||
|
||||
# ------------------------------------------------------------- internals
|
||||
def _text_for_state(self) -> str:
|
||||
if self._state == RECORDING:
|
||||
return t("record.state_recording", duration=format_elapsed(self._duration_s))
|
||||
if self._state == CLOSING:
|
||||
return t("record.state_closing")
|
||||
if self._state == ERROR:
|
||||
return t("record.state_error", count=self._errors)
|
||||
if self._state == DONE:
|
||||
return t("record.state_done", frames=self._frames)
|
||||
return t("record.state_idle")
|
||||
|
||||
def _tooltip(self) -> str:
|
||||
if self._state == IDLE:
|
||||
return t("record.strip_idle")
|
||||
return t(
|
||||
"record.strip_tip",
|
||||
state=self._text_for_state(),
|
||||
frames=self._frames,
|
||||
segments=self._segments,
|
||||
duration=format_elapsed(self._duration_s),
|
||||
dropped=self._dropped,
|
||||
dropped_data=self._dropped_data,
|
||||
dropped_video=self._dropped_video,
|
||||
dropped_gaps=self._dropped_gaps,
|
||||
errors=self._errors,
|
||||
detail=self._detail or t("record.strip_no_error"),
|
||||
path=self._directory or t("record.strip_no_path"),
|
||||
)
|
||||
|
||||
def _leading_token(self) -> str:
|
||||
# Anything dropped is worth warning about even while the state is green;
|
||||
# a write failure outranks it.
|
||||
if self._state == ERROR:
|
||||
return _STATE_TOKENS[ERROR]
|
||||
if self._dropped > 0:
|
||||
return "warning"
|
||||
return _STATE_TOKENS[self._state]
|
||||
|
||||
def _refresh(self) -> None:
|
||||
text = self._text_for_state()
|
||||
if self._dropped > 0:
|
||||
text = f"{text} · {t('record.dropped', count=self._dropped)}"
|
||||
# ``setText`` on every frame would re-layout the rail 60 times a second.
|
||||
if text != self.text():
|
||||
self.setText(text)
|
||||
tooltip = self._tooltip()
|
||||
if tooltip != self._tooltip_cache:
|
||||
self._tooltip_cache = tooltip
|
||||
self.setToolTip(tooltip)
|
||||
token = self._leading_token()
|
||||
if token != self._token:
|
||||
# Re-parsing a stylesheet per frame is measurable; the token only
|
||||
# changes when the state (or the theme) does.
|
||||
self._token = token
|
||||
self.setStyleSheet(
|
||||
f"QLabel#recordChip {{ color: {color(self._theme, token)};"
|
||||
" background: transparent; }"
|
||||
)
|
||||
@@ -1,25 +1,31 @@
|
||||
"""Sensor type + config picker with product badge."""
|
||||
"""Sensor type + config picker.
|
||||
|
||||
One row, because it lives at the left of the status rail: a badge naming the
|
||||
selected product, then the product/sensor choice and the config file that
|
||||
belongs to it. The order matters — picking ``box`` / ``mini`` / ``finger`` is
|
||||
what decides which configs exist, so the two sit next to each other with their
|
||||
own labels and tooltips instead of a bare "配置" combo.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtWidgets import (
|
||||
QComboBox,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types
|
||||
|
||||
THUMB_SIZE = 56
|
||||
CONTROL_HEIGHT = 30
|
||||
MAX_CONTROL_WIDTH = 220
|
||||
#: Both combos stay narrow: this row shares one line with the session state.
|
||||
TYPE_WIDTH = 76
|
||||
CONFIG_WIDTH = 120
|
||||
BADGE_WIDTH = 46
|
||||
|
||||
|
||||
class SensorPickerWidget(QWidget):
|
||||
@@ -29,34 +35,27 @@ class SensorPickerWidget(QWidget):
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(8, 6, 8, 6)
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(6)
|
||||
|
||||
self.thumb = QFrame()
|
||||
self.thumb.setObjectName("cardPanel")
|
||||
self.thumb.setFixedSize(THUMB_SIZE, THUMB_SIZE)
|
||||
thumb_layout = QVBoxLayout(self.thumb)
|
||||
thumb_layout.setContentsMargins(2, 2, 2, 2)
|
||||
thumb_layout.setSpacing(0)
|
||||
#: The product badge that used to sit above the combos. Kept as an
|
||||
#: 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.setWordWrap(False)
|
||||
thumb_layout.addWidget(self.thumb_label)
|
||||
|
||||
form = QFormLayout()
|
||||
form.setContentsMargins(0, 0, 0, 0)
|
||||
form.setHorizontalSpacing(8)
|
||||
form.setVerticalSpacing(6)
|
||||
form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
|
||||
# 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")
|
||||
# The old name, kept so callers/tests that hide or tooltip the product
|
||||
# badge keep working.
|
||||
self.thumb = self.thumb_label
|
||||
|
||||
self.type_label = QLabel()
|
||||
self.type_label.setObjectName("fieldLabel")
|
||||
self.type_combo = QComboBox()
|
||||
self.type_combo.setMinimumHeight(CONTROL_HEIGHT)
|
||||
self.type_combo.setMaximumWidth(MAX_CONTROL_WIDTH)
|
||||
self.type_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
self.type_combo.setMinimumWidth(TYPE_WIDTH)
|
||||
self.type_combo.setProperty("rail", "true")
|
||||
self.type_combo.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
for name in list_sensor_types():
|
||||
self.type_combo.addItem(name, name)
|
||||
self.type_combo.currentIndexChanged.connect(self._on_type_index_changed)
|
||||
@@ -64,16 +63,17 @@ class SensorPickerWidget(QWidget):
|
||||
self.config_label = QLabel()
|
||||
self.config_label.setObjectName("fieldLabel")
|
||||
self.config_combo = QComboBox()
|
||||
self.config_combo.setMinimumHeight(CONTROL_HEIGHT)
|
||||
self.config_combo.setMaximumWidth(MAX_CONTROL_WIDTH)
|
||||
self.config_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
self.config_combo.setMinimumWidth(CONFIG_WIDTH)
|
||||
self.config_combo.setProperty("rail", "true")
|
||||
self.config_combo.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
self.config_combo.currentIndexChanged.connect(self._on_config_index_changed)
|
||||
|
||||
form.addRow(self.type_label, self.type_combo)
|
||||
form.addRow(self.config_label, self.config_combo)
|
||||
layout.addWidget(self.thumb_label)
|
||||
layout.addWidget(self.type_label)
|
||||
layout.addWidget(self.type_combo)
|
||||
layout.addWidget(self.config_label)
|
||||
layout.addWidget(self.config_combo, 1)
|
||||
|
||||
layout.addWidget(self.thumb)
|
||||
layout.addLayout(form, 1)
|
||||
self._reload_configs()
|
||||
self._refresh_thumb()
|
||||
self.retranslate()
|
||||
@@ -141,5 +141,9 @@ class SensorPickerWidget(QWidget):
|
||||
def retranslate(self) -> None:
|
||||
self.type_label.setText(t("sensor.type"))
|
||||
self.config_label.setText(t("sensor.config"))
|
||||
self.thumb.setToolTip(t("sensor.thumb_placeholder"))
|
||||
self.type_label.setToolTip(t("sensor.type_tip"))
|
||||
self.config_label.setToolTip(t("sensor.config_tip"))
|
||||
self.type_combo.setToolTip(t("sensor.type_tip"))
|
||||
self.config_combo.setToolTip(t("sensor.config_tip"))
|
||||
self.thumb_label.setToolTip(t("sensor.thumb_placeholder"))
|
||||
self._refresh_thumb()
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
"""Top toolbar: source, camera/video, run/reset, record, units."""
|
||||
"""Top of the studio shell: status rail and a collapsible settings area.
|
||||
|
||||
The rail is what an operator watches while a session runs — phase, device,
|
||||
calibration, data validity and the record channel — plus the three actions that
|
||||
must never be more than one click away (record, run/stop, reset).
|
||||
|
||||
Everything that is *configuration* rather than state — source, camera number,
|
||||
video file, units and the sensor/config pickers — lives in the settings area,
|
||||
collapsed by default. Both live inside one scroll area so a narrow window
|
||||
scrolls sideways instead of squeezing the controls into an unreadable row.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,6 +31,10 @@ from PyQt5.QtWidgets import (
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
from orisys_studio.ui.fit_text import ShrinkableLabel
|
||||
from orisys_studio.ui.record_status import RecordStatusChip
|
||||
|
||||
CONTROL_HEIGHT = 30
|
||||
BUTTON_HEIGHT = 32
|
||||
@@ -38,6 +52,17 @@ def _separator() -> QFrame:
|
||||
return line
|
||||
|
||||
|
||||
def _rail(widget: QWidget) -> QWidget:
|
||||
"""Mark a control as belonging to the thin status rail.
|
||||
|
||||
The rail's height is the app's first visual weight statement: without this
|
||||
property every button would keep the dialog padding and the rail would grow
|
||||
to twice its content.
|
||||
"""
|
||||
widget.setProperty("rail", "true")
|
||||
return widget
|
||||
|
||||
|
||||
def _group(*widgets: QWidget) -> QWidget:
|
||||
box = QWidget()
|
||||
layout = QHBoxLayout(box)
|
||||
@@ -58,28 +83,127 @@ class ToolbarWidget(QWidget):
|
||||
reset_clicked = pyqtSignal()
|
||||
units_changed = pyqtSignal(str)
|
||||
record_toggled = pyqtSignal(bool)
|
||||
#: The settings area was expanded or collapsed by the user.
|
||||
settings_toggled = pyqtSignal(bool)
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
*,
|
||||
theme: str = "dark",
|
||||
sensor_picker: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._theme = str(theme)
|
||||
self._run_tooltip_reason = ""
|
||||
self._run_available = True
|
||||
self._run_phase = "idle"
|
||||
self._settings_expanded = False
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
outer.setSpacing(0)
|
||||
outer.setContentsMargins(2, 2, 2, 2)
|
||||
outer.setSpacing(4)
|
||||
|
||||
self._scroll = QScrollArea()
|
||||
self._scroll.setWidgetResizable(True)
|
||||
self._scroll.setFrameShape(QFrame.NoFrame)
|
||||
self._scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
outer.addWidget(self._scroll)
|
||||
# The rail is always visible and never scrolls: it is one instrument
|
||||
# line, and a scroll area around it would reserve a scrollbar's height
|
||||
# (30 px in the Fusion metrics) even when nothing scrolls.
|
||||
outer.addLayout(self._build_rail(sensor_picker))
|
||||
|
||||
content = QWidget()
|
||||
self._scroll.setWidget(content)
|
||||
layout = QHBoxLayout(content)
|
||||
layout.setContentsMargins(2, 2, 2, 2)
|
||||
layout.setSpacing(10)
|
||||
# Only the settings area scrolls; it is the part that can outgrow a
|
||||
# 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_page = self._build_settings()
|
||||
self._settings_scroll.setWidget(self.settings_page)
|
||||
outer.addWidget(self._settings_scroll)
|
||||
|
||||
self._populate_source_combo()
|
||||
self._populate_units_combo()
|
||||
self._populate_record_modes()
|
||||
self.retranslate()
|
||||
self._apply_source_mode_ui()
|
||||
self.set_settings_expanded(False)
|
||||
|
||||
# --------------------------------------------------------------- building
|
||||
def _build_rail(self, sensor_picker: QWidget | None) -> QHBoxLayout:
|
||||
"""Sensor choice, phase, device, calibration, validity, record, actions.
|
||||
|
||||
The sensor picker leads the line: which product and config the window is
|
||||
configured for is the first thing to read, and it is what everything
|
||||
else (units, plots, calibration) depends on.
|
||||
"""
|
||||
rail = QHBoxLayout()
|
||||
rail.setSpacing(8)
|
||||
|
||||
if sensor_picker is not None:
|
||||
rail.addWidget(sensor_picker)
|
||||
rail.addWidget(_separator())
|
||||
|
||||
self.status_chip = QLabel()
|
||||
self.status_chip.setObjectName("statusChip")
|
||||
self.status_chip.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred)
|
||||
self.sensor_summary = ShrinkableLabel()
|
||||
self.sensor_summary.setObjectName("panelMeta")
|
||||
self.calibration_label = ShrinkableLabel()
|
||||
self.calibration_label.setObjectName("panelMeta")
|
||||
self.data_state = ShrinkableLabel()
|
||||
self.data_state.setObjectName("panelMeta")
|
||||
self.data_state.setVisible(False)
|
||||
self.record_chip = RecordStatusChip(theme=self._theme)
|
||||
|
||||
self.btn_record = QPushButton()
|
||||
self.btn_record.setObjectName("primaryButton")
|
||||
self.btn_record.setMinimumHeight(BUTTON_HEIGHT)
|
||||
self.btn_record.setMinimumWidth(96)
|
||||
self.btn_record.setCheckable(True)
|
||||
self.btn_record.setEnabled(False)
|
||||
self.btn_record.toggled.connect(self._on_record_toggled)
|
||||
|
||||
self.btn_run = QPushButton()
|
||||
self.btn_run.setObjectName("primaryButton")
|
||||
self.btn_run.setMinimumHeight(BUTTON_HEIGHT)
|
||||
self.btn_run.setMinimumWidth(96)
|
||||
self.btn_run.setCheckable(True)
|
||||
self.btn_run.toggled.connect(self._on_run_toggled)
|
||||
|
||||
self.btn_reset = QPushButton()
|
||||
self.btn_reset.setMinimumHeight(BUTTON_HEIGHT)
|
||||
self.btn_reset.setEnabled(False)
|
||||
self.btn_reset.clicked.connect(self.reset_clicked.emit)
|
||||
|
||||
self.btn_settings = QPushButton()
|
||||
self.btn_settings.setObjectName("ghostButton")
|
||||
self.btn_settings.setProperty("compact", "true")
|
||||
self.btn_settings.setMinimumHeight(BUTTON_HEIGHT)
|
||||
self.btn_settings.setCheckable(True)
|
||||
self.btn_settings.toggled.connect(self._on_settings_toggled)
|
||||
|
||||
rail.addWidget(self.status_chip)
|
||||
rail.addWidget(_separator())
|
||||
rail.addWidget(self.sensor_summary)
|
||||
rail.addWidget(self.calibration_label)
|
||||
rail.addWidget(self.data_state)
|
||||
rail.addStretch(1)
|
||||
rail.addWidget(self.record_chip)
|
||||
rail.addWidget(_rail(self.btn_record))
|
||||
rail.addWidget(_rail(self.btn_run))
|
||||
rail.addWidget(_rail(self.btn_reset))
|
||||
rail.addWidget(_rail(self.btn_settings))
|
||||
return rail
|
||||
|
||||
def _build_settings(self) -> QWidget:
|
||||
"""Source, camera/video, search, preview and units.
|
||||
|
||||
The sensor picker is *not* here: it leads the status rail, because the
|
||||
product choice is session state, not a setting to dig for.
|
||||
"""
|
||||
self.settings_page = QWidget()
|
||||
page = QVBoxLayout(self.settings_page)
|
||||
page.setContentsMargins(0, 2, 0, 0)
|
||||
page.setSpacing(6)
|
||||
|
||||
# --- source group -------------------------------------------------
|
||||
self.source_label = QLabel()
|
||||
@@ -132,18 +256,6 @@ class ToolbarWidget(QWidget):
|
||||
self.btn_preview.setCheckable(True)
|
||||
self.btn_preview.toggled.connect(self._on_preview_toggled)
|
||||
|
||||
self.btn_run = QPushButton()
|
||||
self.btn_run.setObjectName("primaryButton")
|
||||
self.btn_run.setMinimumHeight(BUTTON_HEIGHT)
|
||||
self.btn_run.setMinimumWidth(96)
|
||||
self.btn_run.setCheckable(True)
|
||||
self.btn_run.toggled.connect(self._on_run_toggled)
|
||||
|
||||
self.btn_reset = QPushButton()
|
||||
self.btn_reset.setMinimumHeight(BUTTON_HEIGHT)
|
||||
self.btn_reset.setEnabled(False)
|
||||
self.btn_reset.clicked.connect(self.reset_clicked.emit)
|
||||
|
||||
# --- units group --------------------------------------------------
|
||||
self.units_label = QLabel()
|
||||
self.units_label.setObjectName("fieldLabel")
|
||||
@@ -152,46 +264,75 @@ class ToolbarWidget(QWidget):
|
||||
self.units_combo.setMinimumHeight(CONTROL_HEIGHT)
|
||||
self.units_combo.currentIndexChanged.connect(self._on_units_index_changed)
|
||||
|
||||
# --- record group -------------------------------------------------
|
||||
# --- record mode group --------------------------------------------
|
||||
# "What will be recorded" is a per-recording setting, not live state:
|
||||
# it lives here so the rail's labels stay readable at 1280 in both
|
||||
# languages (the record *state* is on the rail, next to the button).
|
||||
self.record_label = QLabel()
|
||||
self.record_label.setObjectName("fieldLabel")
|
||||
self.record_mode_combo = QComboBox()
|
||||
self.record_mode_combo.setMinimumWidth(120)
|
||||
self.record_mode_combo.setMinimumHeight(CONTROL_HEIGHT)
|
||||
self.btn_record = QPushButton()
|
||||
self.btn_record.setObjectName("ghostButton")
|
||||
self.btn_record.setMinimumHeight(BUTTON_HEIGHT)
|
||||
self.btn_record.setMinimumWidth(96)
|
||||
self.btn_record.setCheckable(True)
|
||||
self.btn_record.setEnabled(False)
|
||||
self.btn_record.toggled.connect(self._on_record_toggled)
|
||||
self.record_status = QLabel()
|
||||
self.record_status.setObjectName("panelMeta")
|
||||
self.record_status.setVisible(False)
|
||||
|
||||
layout.addWidget(
|
||||
top_row = QHBoxLayout()
|
||||
top_row.setSpacing(10)
|
||||
top_row.addWidget(
|
||||
_group(self.source_label, self.source_combo, self.source_stack)
|
||||
)
|
||||
layout.addWidget(_separator())
|
||||
layout.addWidget(
|
||||
_group(self.btn_search, self.btn_preview, self.btn_run, self.btn_reset)
|
||||
)
|
||||
layout.addWidget(_separator())
|
||||
layout.addWidget(_group(self.units_label, self.units_combo))
|
||||
layout.addWidget(_separator())
|
||||
layout.addWidget(
|
||||
_group(self.record_label, self.record_mode_combo, self.btn_record,
|
||||
self.record_status)
|
||||
)
|
||||
layout.addStretch(1)
|
||||
top_row.addWidget(_separator())
|
||||
top_row.addWidget(_group(self.btn_search, self.btn_preview))
|
||||
top_row.addWidget(_separator())
|
||||
top_row.addWidget(_group(self.units_label, self.units_combo))
|
||||
top_row.addWidget(_separator())
|
||||
top_row.addWidget(_group(self.record_label, self.record_mode_combo))
|
||||
top_row.addStretch(1)
|
||||
page.addLayout(top_row)
|
||||
|
||||
self._populate_source_combo()
|
||||
self._populate_units_combo()
|
||||
self._populate_record_modes()
|
||||
self.retranslate()
|
||||
self._apply_source_mode_ui()
|
||||
return self.settings_page
|
||||
|
||||
# ------------------------------------------------------------------ API
|
||||
def settings_expanded(self) -> bool:
|
||||
return bool(self._settings_expanded)
|
||||
|
||||
def set_settings_expanded(self, expanded: bool) -> None:
|
||||
self._settings_expanded = bool(expanded)
|
||||
self.btn_settings.blockSignals(True)
|
||||
self.btn_settings.setChecked(self._settings_expanded)
|
||||
self.btn_settings.blockSignals(False)
|
||||
self._settings_scroll.setVisible(self._settings_expanded)
|
||||
self._apply_settings_button_style()
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = str(theme)
|
||||
self.record_chip.set_theme(self._theme)
|
||||
|
||||
def set_sensor_summary(self, text: str) -> None:
|
||||
self.sensor_summary.setText(str(text or ""))
|
||||
# The rail may squeeze this label; the tooltip always carries it whole.
|
||||
self.sensor_summary.setToolTip(self.sensor_summary.text())
|
||||
self.sensor_summary.setVisible(bool(text))
|
||||
|
||||
def set_calibration_summary(self, text: str) -> None:
|
||||
self.calibration_label.setText(str(text or ""))
|
||||
self.calibration_label.setToolTip(self.calibration_label.text())
|
||||
self.calibration_label.setVisible(bool(text))
|
||||
|
||||
def set_data_state(self, text: str, token: str = "text_secondary") -> None:
|
||||
"""Show/clear the validity readout, coloured by a theme token."""
|
||||
text = str(text or "")
|
||||
self.data_state.setText(text)
|
||||
self.data_state.setToolTip(text)
|
||||
self.data_state.setVisible(bool(text))
|
||||
if text:
|
||||
self.data_state.setStyleSheet(
|
||||
f"QLabel#panelMeta {{ color: {color(self._theme, token)};"
|
||||
" background: transparent; }"
|
||||
)
|
||||
|
||||
def set_record_state(self, *args, **kwargs) -> None:
|
||||
"""Delegate the record strip to :class:`RecordStatusChip`."""
|
||||
self.record_chip.set_state(*args, **kwargs)
|
||||
|
||||
def source_mode(self) -> str:
|
||||
data = self.source_combo.currentData()
|
||||
return str(data or "camera")
|
||||
@@ -337,27 +478,36 @@ class ToolbarWidget(QWidget):
|
||||
self._refresh_record_label()
|
||||
|
||||
def set_record_status(self, text: str) -> None:
|
||||
"""Show elapsed time / output location next to the record control."""
|
||||
self.record_status.setText(str(text or ""))
|
||||
self.record_status.setVisible(bool(text))
|
||||
"""Publish where the recording is being written (shown in the tooltip)."""
|
||||
self.record_chip.set_directory(str(text or ""))
|
||||
|
||||
def _on_record_toggled(self, checked: bool) -> None:
|
||||
self._refresh_record_label()
|
||||
self.record_mode_combo.setEnabled(not checked)
|
||||
self.record_toggled.emit(bool(checked))
|
||||
|
||||
def _on_settings_toggled(self, checked: bool) -> None:
|
||||
self._settings_expanded = bool(checked)
|
||||
self._settings_scroll.setVisible(self._settings_expanded)
|
||||
self._apply_settings_button_style()
|
||||
self.settings_toggled.emit(self._settings_expanded)
|
||||
|
||||
def _apply_settings_button_style(self) -> None:
|
||||
self.btn_settings.setProperty(
|
||||
"settingsOpen", "true" if self._settings_expanded else "false"
|
||||
)
|
||||
self.btn_settings.style().unpolish(self.btn_settings)
|
||||
self.btn_settings.style().polish(self.btn_settings)
|
||||
|
||||
def _refresh_record_label(self) -> None:
|
||||
if self.btn_record.isChecked():
|
||||
self.btn_record.setText(t("record.stop_label"))
|
||||
self.btn_record.setObjectName("dangerButton")
|
||||
else:
|
||||
self.btn_record.setText(t("record.start_label"))
|
||||
self.btn_record.setObjectName("ghostButton")
|
||||
self.btn_record.style().unpolish(self.btn_record)
|
||||
self.btn_record.style().polish(self.btn_record)
|
||||
self.btn_record.setToolTip(
|
||||
t("record.stop_tip") if self.btn_record.isChecked() else t("record.start_tip")
|
||||
)
|
||||
self._refresh_emphasis()
|
||||
|
||||
def _apply_source_mode_ui(self) -> None:
|
||||
video = self.source_mode() == "video"
|
||||
@@ -400,13 +550,39 @@ class ToolbarWidget(QWidget):
|
||||
self.btn_run.setText(t("toolbar.stop_label"))
|
||||
else:
|
||||
self.btn_run.setText(t("toolbar.run_label"))
|
||||
self.btn_run.setObjectName(
|
||||
"dangerButton" if self.btn_run.isChecked() else "primaryButton"
|
||||
)
|
||||
self.btn_run.style().unpolish(self.btn_run)
|
||||
self.btn_run.style().polish(self.btn_run)
|
||||
self._refresh_emphasis()
|
||||
self._apply_run_tooltip()
|
||||
|
||||
def _refresh_emphasis(self) -> None:
|
||||
"""Exactly one *filled* button at a time.
|
||||
|
||||
The complaint this rule answers is a red Stop dominating a top area in
|
||||
which nothing was happening. Stop stays red and one click away, but only
|
||||
the action of the moment is filled:
|
||||
|
||||
* not recording yet → the record action is the filled primary;
|
||||
* recording → stopping the recording is the filled danger;
|
||||
* connecting → cancel is the filled danger (it is the way out of a hang).
|
||||
"""
|
||||
recording = self.btn_record.isChecked()
|
||||
# The phase counts as running even if the button has not been repainted
|
||||
# yet: the rail must describe the session, not the widget's lag.
|
||||
running = self.btn_run.isChecked() or self._run_phase in ("connecting", "running")
|
||||
if recording:
|
||||
self.btn_record.setObjectName("dangerButton")
|
||||
self.btn_run.setObjectName("dangerOutline")
|
||||
else:
|
||||
self.btn_record.setObjectName("primaryButton")
|
||||
if running and self._run_phase != "connecting":
|
||||
self.btn_run.setObjectName("dangerOutline")
|
||||
elif running:
|
||||
self.btn_run.setObjectName("dangerButton")
|
||||
else:
|
||||
self.btn_run.setObjectName("primaryButton")
|
||||
for button in (self.btn_record, self.btn_run):
|
||||
button.style().unpolish(button)
|
||||
button.style().polish(button)
|
||||
|
||||
def _apply_run_tooltip(self) -> None:
|
||||
if self.btn_run.isChecked():
|
||||
if self._run_phase == "connecting":
|
||||
@@ -439,9 +615,11 @@ class ToolbarWidget(QWidget):
|
||||
self.units_label.setText(t("toolbar.units"))
|
||||
self.units_combo.setItemText(0, t("toolbar.units_newton"))
|
||||
self.units_combo.setItemText(1, t("toolbar.units_raw"))
|
||||
self.record_label.setText(t("record.label"))
|
||||
for index, (value, key) in enumerate(RECORD_MODES):
|
||||
if index < self.record_mode_combo.count():
|
||||
self.record_mode_combo.setItemText(index, t(key))
|
||||
self.record_mode_combo.setToolTip(t("record.mode_tip"))
|
||||
self.btn_settings.setText(t("toolbar.settings"))
|
||||
self.btn_settings.setToolTip(t("toolbar.settings_tip"))
|
||||
self.record_chip.retranslate()
|
||||
self._refresh_record_label()
|
||||
|
||||
Reference in New Issue
Block a user