refactor: 拆出 Studio 状态显示并记录分步迁移计划
让状态显示独立于主窗口,保留录制快照注入入口。验证结果和完整门禁限制见 docs/architecture/studio-window-refactor-plan.zh.md。
This commit is contained in:
@@ -21,7 +21,7 @@ from orisys_app_shared.acquisition.image_backend import (
|
||||
SOURCE_RECOMPUTE,
|
||||
SOURCE_REPLAY,
|
||||
ImageFrame,
|
||||
render_image, # re-exported: tests monkeypatch ``app_window.render_image``
|
||||
render_image as render_image, # re-exported: tests monkeypatch ``app_window.render_image``
|
||||
)
|
||||
from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
|
||||
from orisys_app_shared.force.filters import FilterSettings
|
||||
@@ -31,7 +31,7 @@ from orisys_app_shared.logging import report_error
|
||||
from orisys_app_shared.paths import logo_path, recordings_dir, sensor_json_path
|
||||
from orisys_app_shared.prefs import save_preferences
|
||||
from orisys_app_shared.record import RecordConfig
|
||||
from orisys_app_shared.theme import build_qss, clear_theme_cache, color
|
||||
from orisys_app_shared.theme import build_qss, clear_theme_cache
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import QAction, QActionGroup, QIcon
|
||||
from PySide6.QtWidgets import (
|
||||
@@ -54,13 +54,13 @@ from orisys_studio.replay import ReplayEngine
|
||||
from orisys_studio.replay_controller import ReplayController
|
||||
from orisys_studio.scene3d.prefs import settings_to_prefs
|
||||
from orisys_studio.session_controller import SessionController
|
||||
from orisys_studio.ui import record_status as record_states
|
||||
from orisys_studio.status_presenter import RailState, StatusPresenter
|
||||
from orisys_studio.ui.about_dialog import AboutDialog
|
||||
from orisys_studio.ui.analysis_progress import AnalysisProgress
|
||||
from orisys_studio.ui.arrow_panel import ArrowPanel, bgr_to_qimage
|
||||
from orisys_studio.ui.brand_bar import BrandBar
|
||||
from orisys_studio.ui.cal_panel import CalPanel
|
||||
from orisys_studio.ui.force_plots import CHANNEL_LABELS, ForcePlotsPanel
|
||||
from orisys_studio.ui.force_plots import ForcePlotsPanel
|
||||
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel
|
||||
from orisys_studio.ui.finger_orientation_dialog import FingerOrientationDialog
|
||||
from orisys_studio.ui.plot_settings_dialog import (
|
||||
@@ -250,6 +250,7 @@ class StudioMainWindow(QMainWindow):
|
||||
self.toolbar = ToolbarWidget(theme=self._theme)
|
||||
root.addWidget(self.toolbar)
|
||||
self.status_chip = self.toolbar.status_chip
|
||||
self.status_presenter = StatusPresenter(self.toolbar)
|
||||
# Part B: the analysis pass a replay is served from. The surface sits directly
|
||||
# under the rail, so "what is it doing and can I stop it" is answered where the
|
||||
# transport is; it is hidden until a pass (or a cache hit) has something to say.
|
||||
@@ -1431,184 +1432,54 @@ class StudioMainWindow(QMainWindow):
|
||||
self.statusBar().showMessage(message, int(ms))
|
||||
|
||||
def _refresh_status_chip(self) -> None:
|
||||
chip = getattr(self, "status_chip", None)
|
||||
if chip is None:
|
||||
return
|
||||
# The session normalises the phase once, when it is set, so there is nothing
|
||||
# to sanitise here: a second normalisation is a second rule to keep in step.
|
||||
phase = self._session_phase
|
||||
token = f"status_{phase}"
|
||||
# One dot carries the colour; the text says what the state is, so the
|
||||
# colour is never the only encoding.
|
||||
chip.setText(f"● {t(f'status.phase_{phase}')}")
|
||||
chip.setStyleSheet(
|
||||
f"QLabel#statusChip {{ color: {color(self._theme, token)};"
|
||||
" background: transparent; font-weight: 600; padding: 0 10px 0 4px; }"
|
||||
)
|
||||
self.status_presenter.refresh_phase(self.session.phase, theme=self._theme)
|
||||
|
||||
# ------------------------------------------------------ status rail state
|
||||
def _refresh_status_rail(self) -> None:
|
||||
"""Sensor, source detail, calibration, validity, FPS and device identity.
|
||||
|
||||
The rail is the single place these facts are written; the bottom status
|
||||
bar keeps only transient messages.
|
||||
"""
|
||||
config = self.sensor_picker.config_name()
|
||||
self.toolbar.set_sensor_summary(
|
||||
t(
|
||||
"sensor.summary",
|
||||
type=self.sensor_picker.sensor_type().upper(),
|
||||
config=Path(config).name if config else t("status.config_none"),
|
||||
)
|
||||
)
|
||||
# The picker on the rail already names the camera, so the summary carries
|
||||
# only what it cannot: the frame rate, and — **in the replay view only** —
|
||||
# the video part being played. Naming the video in the live view was how a
|
||||
# replay's file stayed on the rail after switching back to the camera.
|
||||
detail = ""
|
||||
result_type = ""
|
||||
result_progress = None
|
||||
if self._view_preset == "replay":
|
||||
path = self.toolbar.video_path()
|
||||
if path:
|
||||
key = (
|
||||
"status.rail_video_name"
|
||||
if self.replay.has_video()
|
||||
else "status.rail_recent_video"
|
||||
)
|
||||
detail = t(key, name=Path(path).name)
|
||||
# Name the *result's* profile and whether it was reused or just finished, so
|
||||
# changing the sensor picker cannot look like the open figures came from the
|
||||
# newly selected product.
|
||||
session = self.replay_ctl.available_result()
|
||||
if session is not None:
|
||||
provenance = getattr(getattr(session, "reader", None), "provenance", None)
|
||||
result = self.replay_ctl.available_result()
|
||||
if result is not None:
|
||||
provenance = getattr(getattr(result, "reader", None), "provenance", None)
|
||||
result_type = str(getattr(provenance, "sensor_type", "") or "").strip()
|
||||
if result_type:
|
||||
result_label = t("status.rail_result_profile", type=result_type)
|
||||
detail = f"{result_label} · {detail}" if detail else result_label
|
||||
progress = getattr(self.replay_ctl, "_progress", None)
|
||||
progress_state = (
|
||||
str(progress.state()) if progress is not None and hasattr(progress, "state") else ""
|
||||
)
|
||||
if progress_state == "reused":
|
||||
status_text = t("status.rail_result_reused")
|
||||
elif progress_state == "complete":
|
||||
status_text = t("status.rail_result_complete")
|
||||
elif progress_state == "running":
|
||||
status_text = t("status.rail_result_running")
|
||||
else:
|
||||
status_text = t("status.rail_result_ready")
|
||||
detail = f"{detail} · {status_text}" if detail else status_text
|
||||
if self._show_fps and self._latest_fps is not None:
|
||||
fps = f"{self._latest_fps:.1f} FPS"
|
||||
# The cost goes next to the rate because one without the other cannot
|
||||
# be read: "16.7 FPS" is either a slow camera or a pipeline that
|
||||
# cannot keep up, and only this number tells the two apart.
|
||||
if self._latest_process_ms:
|
||||
fps = f"{fps} · {self._latest_process_ms:.0f} ms"
|
||||
# ...and the camera's own rate settles it: a capture rate well above
|
||||
# what we consumed means the frames were there and we did not take
|
||||
# them (the drop counter says how many).
|
||||
if self._latest_capture_fps:
|
||||
fps = f"{fps} · {t('status.rail_camera_fps', fps=f'{self._latest_capture_fps:.1f}')}"
|
||||
if self._latest_capture_dropped:
|
||||
fps = f"{fps} (-{self._latest_capture_dropped})"
|
||||
detail = f"{detail} · {fps}" if detail else fps
|
||||
self.toolbar.set_source_summary(detail)
|
||||
# The full path and the replay's notes do not fit on a 30 px rail, so
|
||||
# they live in the summary's tooltip (the bar that used to show them is
|
||||
# gone: the two views share one layout).
|
||||
tip_parts = [detail, self.toolbar.video_path()]
|
||||
if self._view_preset == "replay" and self.replay.has_video():
|
||||
# Includes the "this is a video, not a recording" note for a bare video.
|
||||
tip_parts.append(self.replay.warnings_text())
|
||||
self.toolbar.set_source_summary_tip(
|
||||
"\n".join(part for part in tip_parts if part)
|
||||
)
|
||||
self.toolbar.set_calibration_summary(self._calibration_summary())
|
||||
if self._last_sample_valid is None:
|
||||
self.toolbar.set_data_state("")
|
||||
elif self._last_sample_valid:
|
||||
self.toolbar.set_data_state(t("status.data_valid"), "success")
|
||||
else:
|
||||
self.toolbar.set_data_state(
|
||||
t("status.data_invalid", count=self._record_error_count), "warning"
|
||||
)
|
||||
result_progress = str(self.analysis_progress.state())
|
||||
self.status_presenter.refresh_rail(RailState(
|
||||
sensor_type=self.sensor_picker.sensor_type(),
|
||||
config_name=self.sensor_picker.config_name(),
|
||||
view_preset=self._view_preset,
|
||||
video_path=self.toolbar.video_path(),
|
||||
has_video=self.replay.has_video(),
|
||||
replay_warnings=(
|
||||
self.replay.warnings_text()
|
||||
if self._view_preset == "replay" and self.replay.has_video() else ""
|
||||
),
|
||||
calibration_summary=self._calibration_summary(),
|
||||
result_sensor_type=result_type,
|
||||
result_progress=result_progress,
|
||||
show_fps=self._show_fps,
|
||||
fps=self.session.latest_fps,
|
||||
process_ms=self.session.latest_process_ms,
|
||||
capture_fps=self.session.latest_capture_fps,
|
||||
capture_dropped=self.session.latest_capture_dropped,
|
||||
sample_valid=self.session.last_sample_valid,
|
||||
record_error_count=self.session.record_error_count,
|
||||
))
|
||||
|
||||
def _calibration_summary(self) -> str:
|
||||
"""What the calibration state means for the three axes.
|
||||
|
||||
Before the first sample the only evidence is whether the device
|
||||
confirmed a calibration version. Afterwards the display decision is the
|
||||
evidence: an axis without a newton curve is an axis without calibration,
|
||||
and the rail must not claim three calibrated axes for a one-axis device.
|
||||
"""
|
||||
if self._last_display_fallback is None:
|
||||
return (
|
||||
t("status.calibrated")
|
||||
if self._active_calibration_id
|
||||
else t("status.uncalibrated")
|
||||
)
|
||||
if self._last_display_fallback or self._last_display_available is None:
|
||||
return t("status.uncalibrated")
|
||||
axes = self._last_display_available
|
||||
if all(axes):
|
||||
return t("status.calibrated_all")
|
||||
names = [CHANNEL_LABELS[index] for index, ok in enumerate(axes) if ok]
|
||||
if not names:
|
||||
return t("status.uncalibrated")
|
||||
return t("status.calibrated_axes", axes="+".join(names))
|
||||
return self.status_presenter.calibration_summary(
|
||||
self.recording.active_calibration,
|
||||
self._last_display_fallback,
|
||||
self._last_display_available,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------- record state
|
||||
def _record_stats(self):
|
||||
"""``(state, stats, directory)`` — the single read of the record channel.
|
||||
|
||||
Kept as one seam so a preview/demo driver can inject a record state
|
||||
without touching the recorder or the writer thread.
|
||||
"""
|
||||
recorder = self.recorder
|
||||
raw_directory = getattr(recorder, "directory", None)
|
||||
directory = Path(raw_directory).name if raw_directory else ""
|
||||
if self.recording.is_recording:
|
||||
return record_states.RECORDING, recorder.stats(), directory
|
||||
if self.recording.report_pending:
|
||||
return record_states.CLOSING, recorder.stats(), directory
|
||||
report = self._record_last_report
|
||||
if report is not None:
|
||||
stats = report.stats
|
||||
state = (
|
||||
record_states.ERROR
|
||||
if (stats.failed or stats.write_errors)
|
||||
else record_states.DONE
|
||||
)
|
||||
return state, stats, directory
|
||||
return record_states.IDLE, None, directory
|
||||
"""Keep the snapshot injection seam used by UI checks and demo screenshots."""
|
||||
return self.status_presenter.record_stats(self.recording)
|
||||
|
||||
def _refresh_record_state(self) -> None:
|
||||
"""Push the recording's live numbers (duration, drops, write errors)."""
|
||||
state, stats, directory = self._record_stats()
|
||||
if stats is None:
|
||||
self.toolbar.set_record_state(state, directory=directory)
|
||||
return
|
||||
self.toolbar.set_record_state(
|
||||
state,
|
||||
duration_s=stats.duration_s,
|
||||
frames=stats.frames,
|
||||
segments=stats.segments,
|
||||
# An unrecorded gap is a lost row too: reporting only the two
|
||||
# queue-overflow counters would understate the loss.
|
||||
dropped=(
|
||||
stats.dropped_data
|
||||
+ stats.dropped_video
|
||||
+ stats.dropped_gaps
|
||||
+ stats.unrecorded_gaps
|
||||
),
|
||||
dropped_data=stats.dropped_data,
|
||||
dropped_video=stats.dropped_video,
|
||||
dropped_gaps=stats.dropped_gaps + stats.unrecorded_gaps,
|
||||
errors=stats.write_errors,
|
||||
detail=stats.last_error,
|
||||
directory=directory,
|
||||
)
|
||||
self.status_presenter.refresh_record(*self._record_stats())
|
||||
|
||||
# ------------------------------------------------------------------ nav
|
||||
def _set_theme(self, theme: str) -> None:
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Render shell status from read-only facts on the GUI thread.
|
||||
|
||||
The presenter holds only the toolbar. It neither owns session state nor calls
|
||||
back into the main window; a rail snapshot is discarded after each refresh.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
from orisys_studio.ui import record_status as record_states
|
||||
from orisys_studio.ui.force_plots import CHANNEL_LABELS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from orisys_app_shared.record import RecordStats
|
||||
|
||||
from orisys_studio.recording import RecordingCoordinator
|
||||
from orisys_studio.ui.toolbar import ToolbarWidget
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RailState:
|
||||
"""One GUI-thread read of the facts needed to paint the status rail."""
|
||||
|
||||
sensor_type: str
|
||||
config_name: str
|
||||
view_preset: str
|
||||
video_path: str
|
||||
has_video: bool
|
||||
replay_warnings: str
|
||||
calibration_summary: str
|
||||
result_sensor_type: str = ""
|
||||
#: None means no result; an empty string means a result with no progress state.
|
||||
result_progress: str | None = None
|
||||
show_fps: bool = False
|
||||
fps: float | None = None
|
||||
process_ms: float | None = None
|
||||
capture_fps: float = 0.0
|
||||
capture_dropped: int = 0
|
||||
sample_valid: bool | None = None
|
||||
record_error_count: int = 0
|
||||
|
||||
|
||||
class StatusPresenter:
|
||||
"""Paint status without changing the acquisition or recording lifecycle."""
|
||||
|
||||
def __init__(self, toolbar: ToolbarWidget) -> None:
|
||||
self._toolbar = toolbar
|
||||
|
||||
def refresh_phase(self, phase: str, *, theme: str) -> None:
|
||||
# The session normalises phase; colour is never the only encoding.
|
||||
chip = self._toolbar.status_chip
|
||||
chip.setText(f"● {t(f'status.phase_{phase}')}")
|
||||
token = f"status_{phase}"
|
||||
chip.setStyleSheet(
|
||||
f"QLabel#statusChip {{ color: {color(theme, token)};"
|
||||
" background: transparent; font-weight: 600; padding: 0 10px 0 4px; }"
|
||||
)
|
||||
|
||||
def refresh_rail(self, state: RailState) -> None:
|
||||
toolbar = self._toolbar
|
||||
toolbar.set_sensor_summary(
|
||||
t(
|
||||
"sensor.summary",
|
||||
type=state.sensor_type.upper(),
|
||||
config=Path(state.config_name).name
|
||||
if state.config_name
|
||||
else t("status.config_none"),
|
||||
)
|
||||
)
|
||||
detail = ""
|
||||
if state.view_preset == "replay":
|
||||
if state.video_path:
|
||||
key = (
|
||||
"status.rail_video_name"
|
||||
if state.has_video
|
||||
else "status.rail_recent_video"
|
||||
)
|
||||
detail = t(key, name=Path(state.video_path).name)
|
||||
# The displayed result keeps its own identity after a picker change.
|
||||
if state.result_progress is not None:
|
||||
if state.result_sensor_type:
|
||||
label = t(
|
||||
"status.rail_result_profile", type=state.result_sensor_type
|
||||
)
|
||||
detail = f"{label} · {detail}" if detail else label
|
||||
if state.result_progress == "reused":
|
||||
status_text = t("status.rail_result_reused")
|
||||
elif state.result_progress == "complete":
|
||||
status_text = t("status.rail_result_complete")
|
||||
elif state.result_progress == "running":
|
||||
status_text = t("status.rail_result_running")
|
||||
else:
|
||||
status_text = t("status.rail_result_ready")
|
||||
detail = f"{detail} · {status_text}" if detail else status_text
|
||||
if state.show_fps and state.fps is not None:
|
||||
fps = f"{state.fps:.1f} FPS"
|
||||
# Processing cost and capture rate distinguish a slow chain from a slow camera.
|
||||
if state.process_ms:
|
||||
fps = f"{fps} · {state.process_ms:.0f} ms"
|
||||
if state.capture_fps:
|
||||
fps = f"{fps} · {t('status.rail_camera_fps', fps=f'{state.capture_fps:.1f}')}"
|
||||
if state.capture_dropped:
|
||||
fps = f"{fps} (-{state.capture_dropped})"
|
||||
detail = f"{detail} · {fps}" if detail else fps
|
||||
toolbar.set_source_summary(detail)
|
||||
tip_parts = [detail, state.video_path]
|
||||
if state.view_preset == "replay" and state.has_video:
|
||||
tip_parts.append(state.replay_warnings)
|
||||
toolbar.set_source_summary_tip("\n".join(part for part in tip_parts if part))
|
||||
toolbar.set_calibration_summary(state.calibration_summary)
|
||||
if state.sample_valid is None:
|
||||
toolbar.set_data_state("")
|
||||
elif state.sample_valid:
|
||||
toolbar.set_data_state(t("status.data_valid"), "success")
|
||||
else:
|
||||
toolbar.set_data_state(
|
||||
t("status.data_invalid", count=state.record_error_count), "warning"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def calibration_summary(
|
||||
active_id: str,
|
||||
fallback: bool | None,
|
||||
available: tuple[bool, bool, bool] | None,
|
||||
) -> str:
|
||||
"""Confirmed identity is evidence until the first per-axis display decision."""
|
||||
if fallback is None:
|
||||
return t("status.calibrated") if active_id else t("status.uncalibrated")
|
||||
if fallback or available is None:
|
||||
return t("status.uncalibrated")
|
||||
if all(available):
|
||||
return t("status.calibrated_all")
|
||||
names = [CHANNEL_LABELS[index] for index, ok in enumerate(available) if ok]
|
||||
if not names:
|
||||
return t("status.uncalibrated")
|
||||
return t("status.calibrated_axes", axes="+".join(names))
|
||||
|
||||
@staticmethod
|
||||
def record_stats(
|
||||
recording: RecordingCoordinator,
|
||||
) -> tuple[str, RecordStats | None, str]:
|
||||
"""Read live stats while closing; the final report is authoritative afterwards."""
|
||||
recorder = recording.recorder
|
||||
raw_directory = getattr(recorder, "directory", None)
|
||||
directory = Path(raw_directory).name if raw_directory else ""
|
||||
if recording.is_recording:
|
||||
return record_states.RECORDING, recorder.stats(), directory
|
||||
if recording.report_pending:
|
||||
return record_states.CLOSING, recorder.stats(), directory
|
||||
report = recording.last_report
|
||||
if report is not None:
|
||||
stats = report.stats
|
||||
state = (
|
||||
record_states.ERROR
|
||||
if (stats.failed or stats.write_errors)
|
||||
else record_states.DONE
|
||||
)
|
||||
return state, stats, directory
|
||||
return record_states.IDLE, None, directory
|
||||
|
||||
def refresh_record(
|
||||
self, state: str, stats: RecordStats | None, directory: str
|
||||
) -> None:
|
||||
if stats is None:
|
||||
self._toolbar.set_record_state(state, directory=directory)
|
||||
return
|
||||
self._toolbar.set_record_state(
|
||||
state,
|
||||
duration_s=stats.duration_s,
|
||||
frames=stats.frames,
|
||||
segments=stats.segments,
|
||||
# A gap that could not be recorded is also a lost row.
|
||||
dropped=stats.dropped_data
|
||||
+ stats.dropped_video
|
||||
+ stats.dropped_gaps
|
||||
+ stats.unrecorded_gaps,
|
||||
dropped_data=stats.dropped_data,
|
||||
dropped_video=stats.dropped_video,
|
||||
dropped_gaps=stats.dropped_gaps + stats.unrecorded_gaps,
|
||||
errors=stats.write_errors,
|
||||
detail=stats.last_error,
|
||||
directory=directory,
|
||||
)
|
||||
Reference in New Issue
Block a user