修正 Studio 力曲线原点、运行不可用提示与禁用按钮样式

FN 轴视图留 8% 余量使 0 刻度与静止曲线离开边框;实时视图在轨道上直接写出运行不可用的原因;禁用按钮改为虚线无填充,复选框加勾,单选框选中不再变方,并放大勾选框、微调框箭头、滚动条与菜单行以便触屏操作。另修 ui_check 桩 worker 签名与英文回放提示。在 Linux 离屏环境对比未改动的 HEAD 验证:studio 失败集合为 HEAD 的子集,ui_check 结果一致。
This commit is contained in:
ykcheungab
2026-10-18 14:39:13 +08:00
parent 7b5ffa74f6
commit 85d0b396e3
9 changed files with 226 additions and 49 deletions
+3 -4
View File
@@ -128,8 +128,7 @@
"cancel_label": "■ Cancel",
"cancel_tip": "Abort the connection (always available while connecting, shortcut Esc)",
"run_unavailable": "Not available yet",
"run_disabled_video": "Select a video file first",
"run_video_in_replay": "Play a video from the History replay view (Play and the chart cursor)",
"run_video_in_replay": "Live uses a camera: press Use camera",
"run_disabled_sensor": "Run is not available for this sensor type",
"playback": "▶ Play",
"playback_pause": "⏸ Pause",
@@ -227,8 +226,8 @@
"field_depth": "depth",
"recompute_pipeline": "Recomputed from the recording (not the recorded value)",
"recompute_pipeline_tip": "This frame is what the current algorithm version computes for that footage — not the measurement that was recorded at the time.",
"replay_empty": "No video open\nChoose “Recorded video…” above",
"arrow_empty": "No image yet\nPick a video or camera, then press Run",
"replay_empty": "No video open\nPress “Choose a recorded video…” above",
"arrow_empty": "No image yet\nConnect a camera, then press Run above\nOpen recorded videos in Replay",
"arrow_empty_tip": "The deformation arrow image appears here while running or previewing",
"arrow_render_failed": "Flow arrows could not be drawn for this frame\nTry another view, or reload the video",
"arrow_capture_unavailable": "The captured frame is not available for this overlay\nThe raw camera view is still one selection away",
+2 -3
View File
@@ -128,8 +128,7 @@
"cancel_label": "■ 断开",
"cancel_tip": "中断连接(连接卡住时随时可用,快捷键 Esc)",
"run_unavailable": "当前不可运行",
"run_disabled_video": "请先选择一个视频文件",
"run_video_in_replay": "请在「历史回放」视图里播放视频(用播放与曲线游标)",
"run_video_in_replay": "实时视图只用摄像机:请点「使用摄像机」",
"run_disabled_sensor": "此传感器类型暂不支持运行",
"playback": "▶ 播放",
"playback_pause": "⏸ 暂停",
@@ -228,7 +227,7 @@
"recompute_pipeline": "由录像重算(非记录值)",
"recompute_pipeline_tip": "这一帧是当前算法版本对录像重新计算得到的,不是当初记录下来的测量值。",
"replay_empty": "尚未打开录像\n点击顶部「选择录制视频…」",
"arrow_empty": "尚未采集图像\n请选择视频或摄像机后点击「运行」",
"arrow_empty": "尚未采集图像\n连接摄像机后点击上方「运行」\n录好的视频请到「回放」中打开",
"arrow_empty_tip": "运行或预览后在此显示形变箭头图",
"arrow_render_failed": "本帧无法绘制形变箭头\n请换一种视图,或重新加载视频",
"arrow_capture_unavailable": "当前无法取得采集图像用于叠加\n原始相机画面仍可在同一选择器中查看",
+2 -5
View File
@@ -1344,12 +1344,9 @@ class StudioMainWindow(QMainWindow):
elif self.toolbar.source_mode() == "video":
# Live acquires from a camera. A video file is played from the History
# replay view (Play and the chart cursor), not by starting a chain here.
# Choosing a file would not help here, so the reason never asks for one.
source_ok = False
reason = (
t("toolbar.run_video_in_replay")
if self.toolbar.video_path()
else t("toolbar.run_disabled_video")
)
reason = t("toolbar.run_video_in_replay")
self.toolbar.set_run_tooltip_reason(reason)
# While a session exists the transport stays enabled — pausing and resuming
# are the whole point of it being checked, not an exception to an idle rule.
@@ -60,6 +60,28 @@ CURSOR_READOUT_S = 0.5
#: counts as a *selection* rather than the default "whole session" fit. 1% of the
#: session absorbs the float noise of a fit without hiding a real zoom.
SELECTION_EPSILON = 0.01
#: Share of a fixed axis span left free beyond each configured bound. Without it
#: the bound labels (FN's "0") sit on the plot frame and are not drawn, and a
#: resting FN curve is painted on the border it shares with the FX subplot. 8 %
#: is about half a label's height on the smallest subplot ui_check measures.
AXIS_MARGIN_FRACTION = 0.08
def _display_range(lower: float, upper: float) -> tuple[float, float]:
"""The view range that shows ``lower…upper`` with a margin on both sides.
The configured range — and the ticks — stay exactly what the operator set;
only the drawn view is wider. A margin never brings zero into view when the
configured range excludes it, so the zero line keeps meaning "zero is here".
"""
pad = (float(upper) - float(lower)) * AXIS_MARGIN_FRACTION
view_lower = lower - pad
if lower > 0.0 >= view_lower:
view_lower = lower
view_upper = upper + pad
if upper < 0.0 <= view_upper:
view_upper = upper
return view_lower, view_upper
def _window_brush(theme: str):
@@ -316,6 +338,9 @@ class ForcePlotsPanel(QFrame):
for i, _name in enumerate(CHANNEL_LABELS):
plot = self.plot_widget.addPlot(row=i, col=0)
plot.showGrid(x=True, y=True, alpha=GRID_ALPHA)
# A bound label (FN's 0) may reach past the axis end by a few pixels on
# a short subplot; drawing it is better than losing the origin.
plot.getAxis("left").setStyle(hideOverlappingLabels=False)
# Panning and zooming are always available: a chart that ignores the
# mouse until it is already browsing could never *enter* browsing by
# being dragged. Y stays locked to the configured limits.
@@ -719,7 +744,7 @@ class ForcePlotsPanel(QFrame):
self.plots[i].setXRange(*state.x_range, padding=0.0)
if use_fixed:
ymin, ymax = self._fixed_y_range(i)
self.plots[i].setYRange(ymin, ymax, padding=0.0)
self.plots[i].setYRange(*_display_range(ymin, ymax), padding=0.0)
self.plots[i].getAxis("left").setTicks(
[_nice_ticks(ymin, ymax, count=5)]
)
@@ -1363,7 +1388,7 @@ class ForcePlotsPanel(QFrame):
for i, plot in enumerate(self.plots):
if use_fixed:
ymin, ymax = self._fixed_y_range(i)
plot.setYRange(ymin, ymax, padding=0.0)
plot.setYRange(*_display_range(ymin, ymax), padding=0.0)
plot.getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)])
else:
plot.getAxis("left").setTicks(None)
+37 -1
View File
@@ -181,6 +181,16 @@ class ToolbarWidget(QWidget):
self.data_state = ShrinkableLabel()
self.data_state.setObjectName("panelMeta")
self.data_state.setVisible(False)
# Why the transport cannot start, as text on the rail. A tooltip alone was
# the only place this was said, and a touch screen has no hover to show it.
# Ignored horizontally, so it only ever gets the rail's spare width and
# never squeezes a button; hidden when empty, so it costs no spacing.
self.run_hint = ShrinkableLabel()
self.run_hint.setObjectName("panelMeta")
self.run_hint.setSizePolicy(
QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred
)
self.run_hint.setVisible(False)
self.record_chip = RecordStatusChip(theme=self._theme)
# --- capture source (one of the two groups is visible) -------------
@@ -288,7 +298,9 @@ class ToolbarWidget(QWidget):
rail.addWidget(self.source_summary)
rail.addWidget(self.calibration_label)
rail.addWidget(self.data_state)
rail.addStretch(1)
# Shown, the hint takes the spare width (stretch 1); hidden, the spacer does.
rail.addWidget(self.run_hint, 1)
rail.addStretch(0)
rail.addWidget(self.record_chip)
rail.addWidget(_rail(self.btn_record))
rail.addWidget(_rail(self.btn_run))
@@ -358,6 +370,7 @@ class ToolbarWidget(QWidget):
def set_theme(self, theme: str) -> None:
self._theme = str(theme)
self.record_chip.set_theme(self._theme)
self._apply_run_hint()
def set_sensor_summary(self, text: str) -> None:
"""The rail's sensor button text (``BOX · box/sensor.json``)."""
@@ -432,6 +445,7 @@ class ToolbarWidget(QWidget):
# The reset is the one rail control whose *target* moves with the view (see
# ``reset_target``), so its label is refreshed with the other two.
self._refresh_reset_label()
self._apply_run_hint()
def run_role(self) -> str:
return self._run_role
@@ -557,11 +571,33 @@ class ToolbarWidget(QWidget):
self._run_available = bool(enabled)
self.btn_run.setEnabled(bool(enabled))
self._apply_run_tooltip()
self._apply_run_hint()
def set_run_tooltip_reason(self, reason: str) -> None:
"""Explain *why* Run is unavailable (shown in the button tooltip)."""
self._run_tooltip_reason = str(reason or "")
self._apply_run_tooltip()
self._apply_run_hint()
def _apply_run_hint(self) -> None:
"""Show the reason on the rail while acquisition cannot start.
Only the *blocking* reason is shown: an advisory one (a bare video plays
with empty charts) stays in the tooltip, so the rail is not a second
status bar. The replay view is left out on purpose: its empty 2D pane
already says which button opens a recording, and the extra rail item
would cost the width the replay rail does not have at 1280 px.
"""
blocked = not self._run_available and self._run_role != "playback"
text = self._run_tooltip_reason if blocked else ""
self.run_hint.setText(text)
self.run_hint.setToolTip(text)
self.run_hint.setVisible(bool(text))
if text:
self.run_hint.setStyleSheet(
f"QLabel#panelMeta {{ color: {color(self._theme, 'warning')};"
" background: transparent; }"
)
def set_run_phase(self, phase: str) -> None:
"""Track the session phase.
+43 -7
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import numpy as np
from orisys_app_shared.i18n import t
from orisys_studio.ui.force_plots import ForcePlotsPanel, _nice_ticks
from orisys_studio.ui.force_plots import ForcePlotsPanel, _display_range, _nice_ticks
def _panel(unit: str = "raw") -> ForcePlotsPanel:
@@ -23,6 +23,12 @@ def _ticks(panel: ForcePlotsPanel, channel: int) -> list[float]:
return [float(value) for value, _text in levels[0]]
def _view(panel: ForcePlotsPanel, channel: int) -> tuple[float, float]:
"""The Y range the subplot actually draws."""
low, high = panel.plots[channel].getViewBox().viewRange()[1]
return float(low), float(high)
def _curve(panel: ForcePlotsPanel, channel: int) -> np.ndarray:
_times, values = panel.curves[channel].getData()
return np.asarray(values, dtype=float)
@@ -82,14 +88,15 @@ def test_the_configured_ranges_reach_every_axis(qt_app):
)
panel.set_fixed_newton(True)
# The panel starts in raw, so the raw limits are the ones on the axes.
assert panel.plots[0].getViewBox().viewRange()[1] == [-100000.0, 1000000.0]
assert panel.plots[1].getViewBox().viewRange()[1] == [-500000.0, 500000.0]
# The panel starts in raw, so the raw limits are the ones on the axes (drawn
# with a small margin beyond each bound; see ``_display_range``).
assert _view(panel, 0) == _display_range(-100000.0, 1000000.0)
assert _view(panel, 1) == _display_range(-500000.0, 500000.0)
panel.set_units("N")
assert panel.plots[0].getViewBox().viewRange()[1] == [-5.0, 15.0]
assert panel.plots[1].getViewBox().viewRange()[1] == [-3.0, 3.0]
assert panel.plots[2].getViewBox().viewRange()[1] == [-3.0, 3.0]
assert _view(panel, 0) == _display_range(-5.0, 15.0)
assert _view(panel, 1) == _display_range(-3.0, 3.0)
assert _view(panel, 2) == _display_range(-3.0, 3.0)
assert panel._fixed_y_range(0) == (-5.0, 15.0)
assert panel._fixed_y_range(1) == (-3.0, 3.0)
# A range that starts below zero carries an explicit 0 tick: that is the
@@ -164,3 +171,32 @@ def test_the_default_ranges_are_the_ones_they_always_were(qt_app):
(15.0, "15"),
(20.0, "20"),
]
def test_the_default_fn_origin_is_drawn_off_the_frame(qt_app):
"""FN starts at 0, yet its 0 label and resting curve stay off the plot border.
With the view ending exactly at 0 the label was not drawn and an idle FN
curve lay on the frame shared with FX — the customer's "缺少原点" report.
"""
for unit in ("raw", "N"):
panel = ForcePlotsPanel(theme="dark")
panel.set_units(unit)
panel.set_fixed_newton(True)
ymin, ymax = panel._fixed_y_range(0)
assert ymin == 0.0, "the configured FN floor stays 0"
view_min, view_max = _view(panel, 0)
assert view_min < 0.0 < view_max, unit
assert view_max > ymax, unit
assert 0.0 in _ticks(panel, 0), unit
assert panel.zero_lines[0].isVisibleTo(panel.plots[0].getViewBox()), unit
def test_a_margin_never_brings_an_excluded_zero_into_view():
# A margin that stays on its own side of zero is kept...
assert _display_range(100.0, 1100.0) == (20.0, 1180.0)
# ...one that would cross zero stops at the configured bound.
assert _display_range(10.0, 1010.0) == (10.0, 1090.0)
assert _display_range(-1010.0, -10.0) == (-1090.0, -10.0)
# A range that already holds zero gets the full margin on both sides.
assert _display_range(0.0, 20.0) == (-1.6, 21.6)
+17 -2
View File
@@ -1576,6 +1576,8 @@ def test_live_run_refuses_a_video_source(make_window, monkeypatch):
window._set_source_mode("video")
QApplication.processEvents()
assert not window.toolbar.btn_run.isEnabled(), "Run is offered for a video file in live"
# The reason is on the rail as text, not only in a tooltip (no hover on touch).
assert window.toolbar.run_hint.text() == t("toolbar.run_video_in_replay")
called: list[str] = []
monkeypatch.setattr(
"orisys_app_shared.acquisition.sdk_worker.orisys_importable",
@@ -3138,6 +3140,19 @@ def test_force_threshold_is_on_for_every_sensor_by_default(make_window):
_finish(window, stuck)
def test_the_run_hint_clears_once_run_is_available(make_window):
from PySide6.QtWidgets import QApplication
window = make_window(sensor_type="finger", source_mode="camera")
try:
window.brand_bar.btn_live.click()
QApplication.processEvents()
window._set_source_mode("video")
QApplication.processEvents()
assert window.toolbar.run_hint.text()
window._set_source_mode("camera")
QApplication.processEvents()
assert window.toolbar.btn_run.isEnabled()
assert window.toolbar.run_hint.text() == ""
finally:
window.close()
+4 -1
View File
@@ -298,7 +298,10 @@ class _StuckWorker(RecordSinkMixin):
mono_t = float(getattr(packet, "mono_t", 0.0) or 0.0)
self.on_reset_applied(frame_id, mono_t, 1)
def start(self, _source, *, config_ref=None) -> None:
def start(
self, _source, *, config_ref=None, cal_path=None, device_id=None
) -> None:
# Same keywords as the real workers' ``start``; the stub ignores them.
self._release.clear()
self._thread = threading.Thread(target=self._hang, daemon=True)
self._thread.start()
+91 -24
View File
@@ -69,6 +69,51 @@ def _paint_arrow(kind: str, fill: str) -> str:
return ""
@lru_cache(maxsize=8)
def _paint_check(fill: str) -> str:
"""Draw a tick PNG for a checked checkbox; return its path ('' on failure).
A checked box used to be a solid square, which at 15 px reads as "disabled"
or "indeterminate" rather than "on".
"""
try:
from PySide6.QtCore import QPointF, Qt
from PySide6.QtGui import QColor, QImage, QPainter, QPainterPath, QPen
except Exception: # pragma: no cover - headless import failure
return ""
try:
size = 36 # drawn at 2x the indicator so the tick stays crisp when scaled
safe = fill.strip().lstrip("#").replace("/", "")
path = _arrow_icon_cache_dir() / f"check_{safe}.png"
if path.is_file():
return str(path)
image = QImage(size, size, QImage.Format.Format_ARGB32)
image.fill(Qt.GlobalColor.transparent)
painter = QPainter(image)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
pen = QPen(QColor(fill), size * 0.13)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
tick = QPainterPath(QPointF(size * 0.22, size * 0.52))
tick.lineTo(QPointF(size * 0.42, size * 0.72))
tick.lineTo(QPointF(size * 0.78, size * 0.30))
painter.drawPath(tick)
painter.end()
if not image.save(str(path), "PNG"):
return ""
return str(path)
except Exception: # pragma: no cover - never break theming
return ""
def _check_image(fill: str) -> str:
path = _paint_check(fill)
if not path:
return "none"
return 'url("' + path.replace("\\", "/") + '")'
def _arrow_image(kind: str, fill: str, size: int = 14) -> str:
path = _paint_arrow(kind, fill)
if not path:
@@ -86,6 +131,8 @@ def build_qss(theme: str = "dark") -> str:
arrow_up = _arrow_image("up", dim)
arrow_down_disabled = _arrow_image("down", muted)
arrow_up_disabled = _arrow_image("up", muted)
check_mark = _check_image(accent_text)
check_mark_disabled = _check_image(muted)
return f"""
QWidget {{
background-color: {c['window_bg']};
@@ -117,7 +164,7 @@ QMenu {{
padding: 4px;
}}
QMenu::item {{
padding: 6px 18px;
padding: 8px 20px;
border-radius: 4px;
}}
QMenu::item:selected {{
@@ -219,10 +266,13 @@ QPushButton:pressed {{
QPushButton:focus {{
border-color: {c['focus_ring']};
}}
/* Disabled = no fill and a dashed outline. A filled disabled button was the
same colour as an enabled neutral one, so a rail of unavailable actions
read as a rail of available ones. */
QPushButton:disabled {{
color: {c['text_muted']};
background-color: {c['window_bg']};
border: 1px solid {c['separator']};
background-color: transparent;
border: 1px dashed {c['input_border']};
}}
QPushButton#primaryButton {{
background-color: {c['accent']};
@@ -241,9 +291,9 @@ QPushButton#primaryButton:checked {{
border-color: {c['accent']};
}}
QPushButton#primaryButton:disabled {{
background-color: {c['panel_border']};
background-color: transparent;
color: {c['text_muted']};
border-color: {c['panel_border']};
border: 1px dashed {c['input_border']};
}}
QPushButton#dangerButton {{
background-color: {c['danger_fill']};
@@ -262,9 +312,9 @@ QPushButton#dangerButton:checked {{
border-color: {c['danger_fill']};
}}
QPushButton#dangerButton:disabled {{
background-color: {c['panel_border']};
background-color: transparent;
color: {c['text_muted']};
border-color: {c['panel_border']};
border: 1px dashed {c['input_border']};
}}
QPushButton#ghostButton {{
background-color: transparent;
@@ -310,8 +360,8 @@ QPushButton#dangerOutline:checked {{
}}
QPushButton#dangerOutline:disabled {{
color: {c['text_muted']};
background-color: {c['window_bg']};
border: 1px solid {c['separator']};
background-color: transparent;
border: 1px dashed {c['input_border']};
}}
QPushButton[compact="true"] {{
padding: 6px 9px;
@@ -428,9 +478,12 @@ QComboBox QAbstractItemView {{
selection-background-color: {c['menu_hover']};
selection-color: {c['text_primary']};
}}
QComboBox QAbstractItemView::item {{
min-height: 30px;
}}
QAbstractSpinBox::up-button, QAbstractSpinBox::down-button {{
subcontrol-origin: border;
width: 18px;
width: 22px;
background: transparent;
border: none;
}}
@@ -457,15 +510,17 @@ QAbstractSpinBox::down-arrow:disabled {{
image: {arrow_down_disabled};
}}
/* ---------- Check / radio ---------- */
/* 18 px boxes and a taller row: the label is part of the hit area, and the
whole row has to be easy to hit with a finger. */
QCheckBox, QRadioButton {{
background: transparent;
color: {c['text_primary']};
spacing: 7px;
padding: 2px 0;
spacing: 8px;
padding: 4px 0;
}}
QCheckBox::indicator, QRadioButton::indicator {{
width: 15px;
height: 15px;
width: 18px;
height: 18px;
background-color: {c['input_bg']};
border: 1px solid {c['input_border']};
}}
@@ -473,23 +528,34 @@ QCheckBox::indicator {{
border-radius: 4px;
}}
QRadioButton::indicator {{
border-radius: 8px;
border-radius: 10px;
}}
QCheckBox::indicator:hover, QRadioButton::indicator:hover {{
border-color: {c['accent_hover']};
}}
QCheckBox::indicator:checked, QRadioButton::indicator:checked {{
QCheckBox::indicator:checked {{
background-color: {c['accent']};
border: 4px solid {c['accent']};
border: 1px solid {c['accent']};
image: {check_mark};
}}
/* QSS borders add to ``width``: a 5 px ring on an 18 px box grew the checked
radio to 28 px and turned the circle into a rounded square. Shrinking the
content keeps the outer size (20 px) equal to the unchecked one. */
QRadioButton::indicator:checked {{
width: 10px;
height: 10px;
background-color: {c['input_bg']};
border: 4px solid {c['accent']};
border: 5px solid {c['accent']};
}}
QCheckBox::indicator:disabled, QRadioButton::indicator:disabled {{
background-color: {c['window_bg']};
border-color: {c['separator']};
}}
QCheckBox::indicator:checked:disabled {{
background-color: {c['separator']};
border-color: {c['separator']};
image: {check_mark_disabled};
}}
/* ---------- Progress ---------- */
QProgressBar {{
background-color: {c['input_bg']};
@@ -549,26 +615,26 @@ QTableCornerButton::section {{
/* ---------- Scrollbars ---------- */
QScrollBar:vertical {{
background: {c['scrollbar_bg']};
width: 10px;
width: 12px;
margin: 0;
border: none;
}}
QScrollBar:horizontal {{
background: {c['scrollbar_bg']};
height: 10px;
height: 12px;
margin: 0;
border: none;
}}
QScrollBar::handle:vertical {{
background: {c['scrollbar_handle']};
min-height: 28px;
border-radius: 5px;
min-height: 32px;
border-radius: 6px;
margin: 2px;
}}
QScrollBar::handle:horizontal {{
background: {c['scrollbar_handle']};
min-width: 28px;
border-radius: 5px;
min-width: 32px;
border-radius: 6px;
margin: 2px;
}}
QScrollBar::handle:vertical:hover, QScrollBar::handle:horizontal:hover {{
@@ -707,3 +773,4 @@ def color(theme: str, key: str, default: str = "#888888") -> str:
def clear_theme_cache() -> None:
load_colors.cache_clear()
_paint_arrow.cache_clear()
_paint_check.cache_clear()