界面改用「精密仪器工作台」布局:顶栏压缩、视图切布局、标定与回放面板不再裁切

- 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:
ykcheungab
2026-09-13 18:54:31 +08:00
parent 0f4552cd4e
commit 35e86ec1f7
26 changed files with 2790 additions and 304 deletions
+334
View File
@@ -62,6 +62,11 @@ def test_children_stay_inside_their_parent(make_window, size):
window = _show(make_window(sensor_type="finger", show_cal_panel=True), size)
oversize = []
for child in visible_descendants(window):
if ui_check._inside_scroll_area(child):
# A scroll area's content is *meant* to exceed its viewport: the
# calibration panel and the settings area scroll instead of
# squeezing their controls into an unreadable width.
continue
parent = child.parentWidget()
if parent is None or not parent.isVisible():
continue
@@ -1145,4 +1150,333 @@ def test_recording_leaves_unavailable_values_empty(make_window, record_root):
_finish(window, stuck)
# ------------------------------------------------------- top rail / settings
def test_top_area_stays_compact(make_window):
"""The rail is read at a glance; it is not a place to stack configuration."""
from orisys_app_shared.i18n import t
window = _show(make_window(), (1440, 880))
collapsed = window.brand_bar.height() + window.toolbar.height()
assert collapsed <= 78, f"the collapsed top area is {collapsed}px tall"
assert window.brand_bar.height() <= 30
assert not window.toolbar.settings_expanded()
assert window.brand_bar.btn_live.text() and window.brand_bar.btn_playback.text()
assert window.toolbar.status_chip.text().strip()
assert window.toolbar.record_chip.text() == t("record.state_idle")
window.toolbar.btn_settings.setChecked(True)
from PyQt5.QtWidgets import QApplication
QApplication.processEvents()
assert window.toolbar.settings_expanded()
assert window.toolbar.source_combo.isVisible()
assert window.sensor_picker.isVisible()
expanded = window.brand_bar.height() + window.toolbar.height()
assert expanded > collapsed, "opening the settings area changed nothing"
assert window.act_settings.isChecked(), "the View menu does not follow the toggle"
assert window.prefs["settings_expanded"] is True
window.toolbar.btn_settings.setChecked(False)
QApplication.processEvents()
assert not window.toolbar.settings_expanded()
@pytest.mark.parametrize("size", RESOLUTIONS)
def test_settings_area_stays_inside_its_container(make_window, size):
"""Opening the settings area must not push a widget past its parent.
The settings area is the one part of the top rail that may scroll: its own
content is exempt, everything else must fit.
"""
from PyQt5.QtWidgets import QApplication
window = _show(make_window(sensor_type="finger", show_cal_panel=True), size)
window._set_settings_expanded(True)
for _ in range(3):
QApplication.processEvents()
oversize = []
for child in visible_descendants(window):
if ui_check._inside_scroll_area(child):
continue
parent = child.parentWidget()
if parent is None or not parent.isVisible():
continue
geometry = child.geometry()
if geometry.width() > parent.width() + 1 or geometry.height() > parent.height() + 1:
oversize.append(
f"{type(child).__name__} {geometry.width()}x{geometry.height()}"
f" > parent {parent.width()}x{parent.height()}"
)
assert not oversize, oversize
def test_idle_run_is_the_only_filled_action(make_window):
window = _show(make_window(), (1440, 880))
assert window.toolbar.btn_run.objectName() == "primaryButton"
assert not window.toolbar.btn_record.isEnabled(), "recording without a session"
def test_connecting_cancel_stays_filled(make_window):
"""Cancel is the way out of a hung device open, so it keeps the weight."""
from PyQt5.QtWidgets import QApplication
window = _show(make_window(), (1440, 880))
stuck = ui_check._StuckWorker()
window._ensure_workers = lambda: None
window._box_worker = stuck
window._finger_worker = stuck
window.toolbar.btn_run.setChecked(True)
QApplication.processEvents()
assert window._session_phase == "connecting"
assert window.toolbar.btn_run.objectName() == "dangerButton"
window.toolbar.btn_run.setChecked(False)
stuck.release()
def test_exactly_one_filled_action_while_acquiring(make_window):
"""Driven through the real entry point: Run, then the first packet."""
window = _show(make_window(), (1440, 880))
stuck = ui_check._StuckWorker()
window._ensure_workers = lambda: None
window._box_worker = stuck
window._finger_worker = stuck
window.toolbar.btn_run.setChecked(True)
window._run_timer.stop()
assert window._session_phase == "connecting"
assert window.toolbar.btn_run.objectName() == "dangerButton", (
"Cancel lost its weight while a connect can hang"
)
stuck.emit(ui_check._fake_packet(1))
window._on_run_tick()
assert window._session_phase == "running"
assert window.toolbar.btn_run.objectName() == "dangerOutline", (
"Stop is still the filled action while a session merely acquires"
)
assert window.toolbar.btn_record.objectName() == "primaryButton"
window.toolbar.set_record_checked(True)
assert window.toolbar.btn_record.objectName() == "dangerButton"
assert window.toolbar.btn_run.objectName() == "dangerOutline", (
"two filled actions share the rail while recording"
)
window.toolbar.set_record_checked(False)
window.toolbar.btn_run.setChecked(False)
stuck.release()
def test_record_strip_separates_acquiring_from_recording(make_window):
from orisys_app_shared.i18n import t
from orisys_app_shared.record.experiment import RecordStats
from orisys_studio.ui import record_status
window, stuck = _running_window(make_window)
chip = window.toolbar.record_chip
assert chip.text() == t("record.state_idle")
assert t("record.state_idle") != t("status.phase_running"), (
"acquiring and recording share one label"
)
real_stats = window._record_stats
window._record_stats = lambda: (
record_status.RECORDING,
RecordStats(duration_s=12.4, frames=372, segments=1, dropped_data=3),
"20240101T000000Z_session0001_deadbeef",
)
window._refresh_record_state()
assert t("record.state_recording", duration="00:12.4") in chip.text()
assert t("record.dropped", count=3) in chip.text()
assert "deadbeef" in chip.toolTip()
# A missing placeholder would leave the raw template in place: ``t`` falls
# back to the key instead of raising, so the tooltip is asserted on content.
assert chip.toolTip() != t("record.strip_tip")
window._record_stats = lambda: (
record_status.ERROR,
RecordStats(frames=10, segments=1, failed=True, write_errors=2, last_error="disk full"),
"",
)
window._refresh_record_state()
assert chip.text() == t("record.state_error", count=2)
assert "disk full" in chip.toolTip()
window._record_stats = real_stats
_finish(window, stuck)
# --------------------------------------------------- curves / arrow legend
def test_axis_readouts_sit_above_the_charts(make_window):
window = _show(make_window(), (1440, 880))
panel = window.force_plots
assert panel.value_group.y() < panel.plot_frame.y(), (
"the axis readouts are not above the charts"
)
for label in panel.value_labels:
assert label.text(), "an axis readout is empty"
assert label.height() >= label.fontMetrics().height(), "a readout is clipped"
assert label.fontMetrics().height() >= 20, "the readouts are not large digits"
assert [
plot.getAxis("bottom").style.get("showValues") for plot in panel.plots
] == [False, False, True], "the three charts do not share one bottom time axis"
def test_overview_region_is_the_timeline_control(make_window):
window, stuck = _running_window(make_window)
panel = window.force_plots
for index in range(120):
stuck.emit(ui_check._fake_packet(index + 100))
window._on_run_tick()
assert window.session_store.count == 120
first, last = window.session_store.extent()
assert last > first, "the session has no extent to browse"
assert panel.window_label.text(), "the overview strip has no readout"
panel.show_all()
assert panel.mode() == "browse"
target = (first + (last - first) * 0.25, first + (last - first) * 0.5)
panel.overview_region.setRegion(target)
dragged = panel.window()
assert abs(dragged[0] - target[0]) < 0.05 and abs(dragged[1] - target[1]) < 0.05, (
f"dragging the overview did not move the window: {dragged} vs {target}"
)
assert panel.mode() == "browse"
window.brand_bar.btn_live.click()
assert panel.mode() == "follow", "the live nav item did not leave history browsing"
_finish(window, stuck)
def test_arrow_legend_follows_the_view_mode(make_window):
from orisys_app_shared.acquisition.arrows import arrow_display_scale
assert arrow_display_scale("box") == 2.0
assert arrow_display_scale("finger") == 1.0
window = _show(make_window(), (1440, 880))
panel = window.arrow_panel
assert panel.scale_label.isVisible()
assert "2" in panel.scale_label.text()
assert panel.pipeline_label.isVisible() and panel.pipeline_label.text()
assert panel.area_label.isVisible() and panel.area_label.text()
assert not panel.colorbar.isVisible(), "the depth colour bar is shown in arrows"
panel.set_view_mode("depth")
panel.set_depth_scale("0.000 – 1.000")
assert panel.colorbar.isVisible(), "the depth view has no colour bar"
assert panel.depth_scale_label.isVisible()
assert not panel.scale_label.isVisible(), "the arrow magnification leaked into depth"
panel.set_view_mode("arrows_pure")
assert not panel.colorbar.isVisible()
assert panel.scale_label.isVisible()
# ------------------------------------------------- top rail: sensor and views
def test_the_sensor_picker_leads_the_rail(make_window):
"""Which product/config is selected must be readable, not buried."""
from orisys_app_shared.i18n import t
window = _show(make_window(), (1440, 880))
picker = window.sensor_picker
assert picker.isVisible(), "the sensor picker is not on the status rail"
assert picker.type_label.text() == t("sensor.type")
assert picker.config_label.text() == t("sensor.config")
assert picker.thumb_label.text() == "BOX"
assert picker.type_label.toolTip() and picker.config_label.toolTip()
# The two combos stay wired: switching the product reloads its configs.
picker.type_combo.setCurrentIndex(picker.type_combo.findData("finger"))
assert picker.sensor_type() == "finger"
assert picker.config_name()
def test_the_nav_items_switch_the_main_window_layout(make_window):
window, stuck = _running_window(make_window)
for index in range(60):
stuck.emit(ui_check._fake_packet(index + 100))
window._on_run_tick()
assert window.session_store.count == 60
window.brand_bar.btn_calibration.click()
assert window.view_preset() == "calibration"
sizes = window.content_splitter.sizes()
assert window.cal_panel.isVisible()
assert not window.arrow_panel.isVisible(), "the calibration view keeps the 2D panel"
assert sizes[3] >= 500, f"the calibration panel is too narrow: {sizes}"
assert window.brand_bar.btn_calibration.property("navActive") == "true"
window.brand_bar.btn_playback.click()
assert window.view_preset() == "replay"
assert window.arrow_panel.isVisible() and not window.cal_panel.isVisible()
assert window.force_plots.mode() == "browse", "the replay view did not open the session"
window.brand_bar.btn_live.click()
assert window.view_preset() == "live"
sizes = window.content_splitter.sizes()
assert sizes[2] > sizes[1], f"the 2D panel is not the smaller one: {sizes}"
assert window.force_plots.mode() == "follow"
assert window.brand_bar.btn_live.property("navActive") == "true"
_finish(window, stuck)
def test_the_calibration_view_does_not_clip_its_controls(make_window):
from PyQt5.QtWidgets import QHeaderView
window = _show(make_window(sensor_type="box", show_cal_panel=True), (1440, 880))
window.brand_bar.btn_calibration.click()
from PyQt5.QtWidgets import QApplication
for _ in range(3):
QApplication.processEvents()
cal = window.cal_panel
assert cal.scroll.isVisible(), "the calibration panel cannot scroll"
assert (
cal.table.horizontalHeader().sectionResizeMode(0) == QHeaderView.ResizeToContents
), "the diagnostic columns can still be elided"
for button in (cal.btn_apply, cal.btn_export, cal.btn_toggle_validation):
assert button.height() >= button.fontMetrics().height(), button.text()
assert button.width() >= button.sizeHint().width(), (
f"{button.text()!r} is clipped: {button.width()} < {button.sizeHint().width()}"
)
def test_the_2d_area_keeps_the_output_aspect_ratio(make_window):
window = _show(
make_window(sensor_type="finger", show_finger_3d=False), (1440, 880)
)
image = window.arrow_panel.image_label
assert abs(image.aspect() - 16.0 / 9.0) < 0.01
assert abs(image.height() - image.width() / image.aspect()) <= 2, (
f"the 2D area does not follow the output aspect: {image.width()}x{image.height()}"
)
def test_the_playback_dialog_labels_its_buttons(make_window):
"""The dialog's three controls had no text at all: it never retranslated."""
from orisys_app_shared.i18n import t
from orisys_studio.ui.playback_dialog import PlaybackDialog
window = _show(make_window(), (1440, 880))
dialog = PlaybackDialog(theme="dark", parent=window)
try:
assert dialog.open_button.text() == t("playback.open")
assert dialog.unit_button.text()
assert dialog.depth_button.text() == t("playback.recompute_depth")
assert dialog.depth_button.toolTip() == t("playback.recompute_depth_tip")
finally:
dialog.close()