采集循环只认新帧,选区统计移到数值卡,状态栏改为会过期的通知行 - What: 1) 选区统计不再挤在曲线下方(中文长句会顶出面板右边界)——框选后三个数值卡的大号数字变成该窗口的峰值、下方小字为该窗口的均值·σ(chart.selection_detail),峰值语义放在卡片 tooltip;曲线下方只保留「选区 4.00s / 121 点」(完整一行留在它的 tooltip 里)。「全程总览」不算选区(那是默认适配),此时卡片回到"游标处样本";切换单位/换数据源/返回最新都会正确切回。2) 上方的 63.0 FPS 是采集循环自己的轮询速率,根因是同一帧被反复当成新样本:CaptureSource.read_latest 返回后台线程的最新帧,没有新帧时把同一帧再给一次,循环未做判断,于是 30 fps 的设备录出约 63 行/秒(frame_id 还各不相同)。新增 _capture_sequence():读 capture_source.status.last_seq,计数未变即"还没有新帧",睡 2 ms 重试、不进处理链、不发 packet;只有带后台读取线程的实时相机有该标记,视频文件是同步解码、每次都取下一帧,因此忽略(行为不变)。packet.fps 改为速率 (窗口帧数-1)/(首末到达差),窗口 1 s——按"1 秒内到达几帧"数数会系统性偏大。容器速率仍取传感器配置值,与此层无关。3) 底部状态栏只留一次性通知:删除右下角常驻「配置 sensor.json」(顶栏摘要按钮已写明),删除与顶栏重复的 status.run_started(运行中(0))与 status.connecting(正在连接…);QStatusBar 换成 _NoticeBar——Qt 的 showMessage(text) 默认 msecs=0 表示永不消失,这正是那句「运行中(0)」一直挂到底部的原因,子类统一补 6 秒超时;status.units_raw_fallback 之前每帧都发(等于永久占住通知行),改为每次运行只提示一次。附带修复:英文下表头模式名被截断(长句移入 tooltip,标签只留模式名 chart.mode_*_tip);数据源切换后曲线下方的选区文案会残留,改为在 _on_source_changed 里清零。 - Why: 用户评审 3 点——选区统计显示不全、顶部 fps 应受硬件限制最大 30、右下角配置文件名与左下角「运行中(0)」和顶栏信息重复。第 2 点查出根因是重复采样(数据缺陷,不只是显示问题),修掉后设备速率与记录行数才真正一致。 - Verification: ui_check.py 546 项 0 失败(新增:选区统计在卡片、卡片小字不裁切、状态栏无常驻标签且消息过期、顶栏 FPS 即 packet 交付速率、表头模式名中英不裁切);test_ui_smoke.py 71 项 0 失败(新增选区读数与通知行过期两例);drive_shared.py 148 项 0 失败(新增 shared/tests/test_worker_frame_rate.py:同一帧返回 3 次时 packet 数≈交付帧数,去掉判据为 11317:39,且 fps 与交付速率一致);locale_studio.py 267 键齐全、zh==en、prefs 对齐;reproduce.py 退出 0;ui_snapshot.py 新增 --select A,B,六张复核图(选区中英、实时、录制中、回放、标定 1280x720)。 - Not in this change: 录制文件格式与 CSV 列序不变;去重只影响"哪些帧算一帧",视频文件源行为不变;曲线下方不再显示三通道峰/均/σ(数值在卡片上,完整一行在 tooltip 里)。
This commit is contained in:
@@ -112,6 +112,22 @@ def _reveal_in_explorer(path: Path) -> None:
|
||||
subprocess.run(["xdg-open", str(folder)], check=False)
|
||||
|
||||
|
||||
class _NoticeBar(QStatusBar):
|
||||
"""A status bar whose messages expire.
|
||||
|
||||
Everything that is a *state* — the phase, the source, the calibration, whether
|
||||
the data is valid — is read from the toolbar's rail. This line is for one-off
|
||||
notices, so a message with no timeout is a defect: it is how an hour-old
|
||||
"运行中(0)" was still sitting under a stopped window. Any caller that asks for
|
||||
a permanent message gets ``DEFAULT_TIMEOUT_MS`` instead.
|
||||
"""
|
||||
|
||||
DEFAULT_TIMEOUT_MS = 6000
|
||||
|
||||
def showMessage(self, text: str, timeout: int = 0) -> None: # noqa: N802 - Qt name
|
||||
super().showMessage(str(text or ""), int(timeout) or self.DEFAULT_TIMEOUT_MS)
|
||||
|
||||
|
||||
class StudioMainWindow(QMainWindow):
|
||||
def __init__(self, prefs: dict[str, Any] | None = None) -> None:
|
||||
super().__init__()
|
||||
@@ -127,6 +143,8 @@ class StudioMainWindow(QMainWindow):
|
||||
self._running = False
|
||||
self._connect_started_at = 0.0
|
||||
self._connect_slow_notified = False
|
||||
#: Whether this run has already said "showing raw, nothing is calibrated".
|
||||
self._raw_fallback_notified = False
|
||||
self._preview = None
|
||||
self._preview_timer = QTimer(self)
|
||||
self._preview_timer.setInterval(33)
|
||||
@@ -226,13 +244,12 @@ class StudioMainWindow(QMainWindow):
|
||||
self.replay_bar.setVisible(False)
|
||||
root.addWidget(self.replay_bar)
|
||||
|
||||
self.setStatusBar(QStatusBar())
|
||||
# The phase/record state lives in the toolbar's rail; the status bar is
|
||||
# for transient messages plus one permanent line of device identity.
|
||||
self.device_label = QLabel()
|
||||
self.device_label.setObjectName("panelMeta")
|
||||
self.statusBar().addPermanentWidget(self.device_label)
|
||||
self.cal_panel.status_message.connect(self.statusBar().showMessage)
|
||||
self.setStatusBar(_NoticeBar())
|
||||
# The status bar carries **one-off notices** only (a recording finished, a
|
||||
# calibration was applied, a failure). The phase, the source and the
|
||||
# validity of the data are already on the toolbar's rail, so repeating
|
||||
# them here only put stale text under the window for the whole session.
|
||||
self.cal_panel.status_message.connect(self._notify)
|
||||
self.cal_panel.calibration_exported.connect(self._on_calibration_exported)
|
||||
self.cal_panel.apply_requested.connect(self._on_calibration_apply)
|
||||
self._set_phase("idle")
|
||||
@@ -1058,10 +1075,18 @@ class StudioMainWindow(QMainWindow):
|
||||
self._refresh_status_chip()
|
||||
self._refresh_status_rail()
|
||||
self._refresh_record_state()
|
||||
if not self._preview_open and not self._running:
|
||||
self.statusBar().showMessage(t("status.ready"))
|
||||
self._update_run_enabled()
|
||||
|
||||
#: How long a one-off notice stays on the status bar (see ``_NoticeBar``).
|
||||
NOTICE_MS = _NoticeBar.DEFAULT_TIMEOUT_MS
|
||||
|
||||
def _notify(self, text: str, ms: int = NOTICE_MS) -> None:
|
||||
"""Show a one-off notice on the status bar and let it expire."""
|
||||
message = str(text or "")
|
||||
if not message:
|
||||
return
|
||||
self.statusBar().showMessage(message, int(ms))
|
||||
|
||||
def _set_phase(self, phase: str) -> None:
|
||||
"""Track the acquisition phase and reflect it in the status chip."""
|
||||
self._session_phase = phase if phase in SESSION_PHASES else "idle"
|
||||
@@ -1117,7 +1142,6 @@ class StudioMainWindow(QMainWindow):
|
||||
self.toolbar.set_data_state(
|
||||
t("status.data_invalid", count=self._record_error_count), "warning"
|
||||
)
|
||||
self._refresh_device_label()
|
||||
|
||||
def _calibration_summary(self) -> str:
|
||||
"""What the calibration state means for the three axes.
|
||||
@@ -1143,14 +1167,6 @@ class StudioMainWindow(QMainWindow):
|
||||
return t("status.uncalibrated")
|
||||
return t("status.calibrated_axes", axes="+".join(names))
|
||||
|
||||
def _refresh_device_label(self) -> None:
|
||||
label = getattr(self, "device_label", None)
|
||||
if label is None:
|
||||
return
|
||||
config = self.sensor_picker.config_name()
|
||||
config_name = Path(config).name if config else t("status.config_none")
|
||||
label.setText(t("status.device_label", config=config_name))
|
||||
|
||||
# ---------------------------------------------------------- record state
|
||||
def _record_stats(self):
|
||||
"""``(state, stats, directory)`` — the single read of the record channel.
|
||||
@@ -1844,6 +1860,7 @@ class StudioMainWindow(QMainWindow):
|
||||
self._set_phase("connecting")
|
||||
self._connect_started_at = time.perf_counter()
|
||||
self._connect_slow_notified = False
|
||||
self._raw_fallback_notified = False
|
||||
self.cal_panel.set_meta_context(sensor_type=sensor_type, config_ref=config_ref)
|
||||
self.cal_panel.set_running(True)
|
||||
self.toolbar.set_source_controls_enabled(False)
|
||||
@@ -1854,7 +1871,8 @@ class StudioMainWindow(QMainWindow):
|
||||
self._update_run_enabled()
|
||||
self._refresh_status_rail()
|
||||
self._refresh_record_state()
|
||||
self.statusBar().showMessage(t("status.connecting"))
|
||||
# The phase chip on the rail already reads 连接中, so there is no status
|
||||
# bar copy of it here (see ``_NoticeBar``).
|
||||
logger.info(
|
||||
"Connecting type=%s source=%s config=%s",
|
||||
sensor_type,
|
||||
@@ -1988,9 +2006,9 @@ class StudioMainWindow(QMainWindow):
|
||||
self._connect_slow_notified = True
|
||||
self.toolbar.set_reset_enabled(True)
|
||||
self._sync_shortcut_actions()
|
||||
self.statusBar().showMessage(
|
||||
t("status.run_started", source=self._run_source_label)
|
||||
)
|
||||
# No status-bar line here: the rail's phase chip says 采集中 and the
|
||||
# source slot names the camera, so "运行中(0)" was a second copy of
|
||||
# the same fact that then stayed on screen for the rest of the session.
|
||||
|
||||
if is_new:
|
||||
self._last_plot_packet_key = packet_key
|
||||
@@ -2005,7 +2023,14 @@ class StudioMainWindow(QMainWindow):
|
||||
sample=record,
|
||||
)
|
||||
if display.using_raw_fallback:
|
||||
self.statusBar().showMessage(t("status.units_raw_fallback"))
|
||||
# Announced once per run, not once per frame: a notice that is
|
||||
# re-issued every 16 ms never expires, and the rail already
|
||||
# carries the calibration state.
|
||||
if not self._raw_fallback_notified:
|
||||
self._raw_fallback_notified = True
|
||||
self._notify(t("status.units_raw_fallback"))
|
||||
else:
|
||||
self._raw_fallback_notified = False
|
||||
self._last_sample_valid = True
|
||||
self._last_display_fallback = bool(display.using_raw_fallback)
|
||||
self._last_display_available = tuple(bool(flag) for flag in display.available)
|
||||
|
||||
@@ -50,6 +50,10 @@ VALUE_FONT_PT = 16
|
||||
#: timeline. One second is wide enough that a sample is always inside it and small
|
||||
#: enough that reading a recording from disk stays free while scrubbing.
|
||||
CURSOR_READOUT_S = 0.5
|
||||
#: How much of the session an edge must be inset by before the visible window
|
||||
#: 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
|
||||
|
||||
|
||||
def _window_brush(theme: str):
|
||||
@@ -140,6 +144,9 @@ class ForcePlotsPanel(QFrame):
|
||||
self._mode = "follow"
|
||||
self._window: tuple[float, float] = (0.0, 0.0)
|
||||
self._cursor_t = 0.0
|
||||
#: The current selection's statistics, or None when the cards describe the
|
||||
#: cursor instead (following live, a fitted session, a replay).
|
||||
self._selection = None
|
||||
self._syncing = False
|
||||
self._overview_dirty = True
|
||||
self._last_overview_at = 0.0
|
||||
@@ -198,17 +205,21 @@ class ForcePlotsPanel(QFrame):
|
||||
values_row.setSpacing(14)
|
||||
self.value_cards: list[QFrame] = []
|
||||
self.value_labels: list[QLabel] = []
|
||||
self.value_details: 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)
|
||||
card, badge, name_label, value_label, unit_label, detail_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)
|
||||
self.value_details.append(detail_label)
|
||||
root.addWidget(self.value_group)
|
||||
|
||||
self.plot_frame = QFrame()
|
||||
@@ -316,11 +327,15 @@ class ForcePlotsPanel(QFrame):
|
||||
|
||||
def _build_value_card(
|
||||
self, channel: str
|
||||
) -> tuple[QFrame, QLabel, QLabel, QLabel, QLabel]:
|
||||
) -> tuple[QFrame, QLabel, 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.
|
||||
|
||||
The third line carries the selection's mean and σ. It exists on every card
|
||||
and is kept at a fixed height even while empty, so entering a selection
|
||||
adds a number instead of moving the charts.
|
||||
"""
|
||||
card = QFrame()
|
||||
card.setObjectName("valueGroup")
|
||||
@@ -354,7 +369,13 @@ class ForcePlotsPanel(QFrame):
|
||||
value_row.addWidget(unit_label)
|
||||
value_row.addStretch(1)
|
||||
layout.addLayout(value_row)
|
||||
return card, badge, name_label, value_label, unit_label
|
||||
|
||||
detail_label = QLabel()
|
||||
detail_label.setObjectName("hintText")
|
||||
detail_label.setFont(_mono_font(9))
|
||||
detail_label.setFixedHeight(detail_label.fontMetrics().height())
|
||||
layout.addWidget(detail_label)
|
||||
return card, badge, name_label, value_label, unit_label, detail_label
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = theme
|
||||
@@ -446,11 +467,13 @@ class ForcePlotsPanel(QFrame):
|
||||
self._mode = "follow"
|
||||
self._window = (0.0, 0.0)
|
||||
self._cursor_t = 0.0
|
||||
self._selection = None
|
||||
self._overview_dirty = True
|
||||
self._interactive(False)
|
||||
self.set_cursor(0.0)
|
||||
self.overview_curve.setData([], [])
|
||||
self.stats_label.setText("")
|
||||
self.stats_label.setToolTip("")
|
||||
self._render_value_cards()
|
||||
self._apply_axis_mode()
|
||||
self._refresh_mode_label()
|
||||
@@ -633,13 +656,17 @@ class ForcePlotsPanel(QFrame):
|
||||
self._clear_curves()
|
||||
self._mode = "follow"
|
||||
self._window = (0.0, 0.0)
|
||||
self._selection = None
|
||||
# The strip described the *previous* source's window: a summary left
|
||||
# behind here would claim a selection that no longer exists.
|
||||
self.stats_label.setText("")
|
||||
self.stats_label.setToolTip("")
|
||||
self._overview_dirty = True
|
||||
self._refresh_mode_label()
|
||||
self._apply_axis_mode()
|
||||
if not self._source_ready:
|
||||
self.overview_curve.setData([], [])
|
||||
self.window_label.setText("")
|
||||
self.stats_label.setText("")
|
||||
return
|
||||
self._sync_overview()
|
||||
self._reload_from_store()
|
||||
@@ -728,6 +755,9 @@ class ForcePlotsPanel(QFrame):
|
||||
self._interactive(mode == "browse")
|
||||
self._refresh_mode_label()
|
||||
self._sync_overview()
|
||||
if mode == "follow":
|
||||
# Leaving the selection: the cards go back to describing the cursor.
|
||||
self._update_selection_readout()
|
||||
self._readout_at_cursor()
|
||||
self.mode_changed.emit(mode)
|
||||
|
||||
@@ -818,6 +848,10 @@ class ForcePlotsPanel(QFrame):
|
||||
return
|
||||
if not self._source_ready:
|
||||
return
|
||||
if self._selection_active():
|
||||
# The cards are stating the selection's peak / mean / σ; the cursor is
|
||||
# inside that window and must not overwrite them.
|
||||
return
|
||||
first, last = self._source.extent()
|
||||
t0 = max(first, self._cursor_t - CURSOR_READOUT_S)
|
||||
t1 = min(last, self._cursor_t + CURSOR_READOUT_S)
|
||||
@@ -837,34 +871,83 @@ class ForcePlotsPanel(QFrame):
|
||||
self._render_value_cards()
|
||||
|
||||
def _update_selection_readout(self) -> None:
|
||||
"""Refresh what the selection is worth: the cards, and the strip's size.
|
||||
|
||||
The per-channel numbers live on the cards (peak, then mean · σ); the strip
|
||||
under the charts keeps only how long the selection is and how many samples
|
||||
it holds, because that is all that fits there. The full line stays in the
|
||||
strip's tooltip.
|
||||
"""
|
||||
if not self._source_ready:
|
||||
self._selection = None
|
||||
self.stats_label.setText("")
|
||||
self.stats_label.setToolTip("")
|
||||
self._render_value_cards()
|
||||
return
|
||||
if not self._selection_active():
|
||||
# Following live, or fitted to the whole session: the cards are the
|
||||
# readout of the cursor, not of a window.
|
||||
self._selection = None
|
||||
self.stats_label.setText("")
|
||||
self.stats_label.setToolTip("")
|
||||
self._render_value_cards()
|
||||
return
|
||||
t0, t1 = self._window
|
||||
stats = self._source.statistics(t0, t1, unit=self._unit)
|
||||
if stats.samples == 0:
|
||||
self._selection = None
|
||||
self.stats_label.setText(t("chart.selection_empty"))
|
||||
self.stats_label.setToolTip("")
|
||||
self._render_value_cards()
|
||||
return
|
||||
self._selection = stats
|
||||
self.stats_label.setText(
|
||||
t(
|
||||
"chart.selection_summary",
|
||||
duration=f"{stats.duration_s:.2f}",
|
||||
samples=stats.samples,
|
||||
)
|
||||
)
|
||||
self.stats_label.setToolTip(self._selection_tooltip(stats))
|
||||
self._render_value_cards()
|
||||
|
||||
def _selection_tooltip(self, stats) -> str:
|
||||
"""The full selection readout, for the strip's tooltip."""
|
||||
unit = unit_display(self._unit)
|
||||
parts = []
|
||||
for index, label in enumerate(CHANNEL_LABELS):
|
||||
peak = stats.peaks[index]
|
||||
mean = stats.means[index]
|
||||
std = stats.stds[index]
|
||||
peak = float(stats.peaks[index])
|
||||
if not np.isfinite(peak):
|
||||
continue
|
||||
parts.append(
|
||||
f"{label} 峰 {peak:.3g}{unit} · 均 {mean:.3g} · σ {std:.3g}"
|
||||
)
|
||||
self.stats_label.setText(
|
||||
t(
|
||||
"chart.selection",
|
||||
duration=f"{stats.duration_s:.2f}",
|
||||
samples=stats.samples,
|
||||
detail=" ".join(parts),
|
||||
f"{label} 峰 {peak:.3g}{unit} · 均 {float(stats.means[index]):.3g} "
|
||||
f"· σ {float(stats.stds[index]):.3g}"
|
||||
)
|
||||
return t(
|
||||
"chart.selection",
|
||||
duration=f"{stats.duration_s:.2f}",
|
||||
samples=stats.samples,
|
||||
detail=" ".join(parts),
|
||||
)
|
||||
|
||||
def _selection_active(self) -> bool:
|
||||
"""Whether the visible window is a *selection* and not the whole session.
|
||||
|
||||
Following live is not a selection, and neither is "whole session" — that is
|
||||
the default fit, where the number the operator wants is the one under the
|
||||
cursor. A window that covers the session (the fit, or a zoom back out to
|
||||
it) therefore keeps the cursor readout.
|
||||
"""
|
||||
if self._mode != "browse" or not self._source_ready:
|
||||
return False
|
||||
first, last = self._source.extent()
|
||||
span = last - first
|
||||
if span <= 0.0:
|
||||
return False
|
||||
start, end = self._window
|
||||
edge = span * SELECTION_EPSILON
|
||||
return (start - first) > edge or (last - end) > edge
|
||||
|
||||
def _sync_overview(self) -> None:
|
||||
if not self._source_ready:
|
||||
return
|
||||
@@ -938,11 +1021,19 @@ class ForcePlotsPanel(QFrame):
|
||||
return 1200
|
||||
|
||||
def _refresh_mode_label(self) -> None:
|
||||
"""The mode name on the header, with its meaning in the tooltip.
|
||||
|
||||
The long form ("Browsing history (display paused; capture and recording
|
||||
continue)") did not fit next to the header's icon buttons at 1280–1440 px
|
||||
in English and was simply cut mid-word, so the parenthetical lives in the
|
||||
tooltip and the label stays a name.
|
||||
"""
|
||||
if self.is_recording_source():
|
||||
key = "chart.mode_replay"
|
||||
else:
|
||||
key = "chart.mode_browse" if self._mode == "browse" else "chart.mode_follow"
|
||||
self.mode_label.setText(t(key))
|
||||
self.mode_label.setToolTip(t(f"{key}_tip"))
|
||||
# "Back to latest" only means something while reading a live session; a
|
||||
# recording has no latest sample to return to.
|
||||
self.btn_latest.setEnabled(
|
||||
@@ -1042,25 +1133,64 @@ class ForcePlotsPanel(QFrame):
|
||||
curve.setData([], [])
|
||||
|
||||
def _render_value_cards(self) -> None:
|
||||
"""One card per channel: what it reads *now*, in the active unit.
|
||||
|
||||
While a selection is being inspected the big number is that window's peak
|
||||
and the line under it its mean and σ — the selection statistics used to be
|
||||
a single long line under the charts, which could not fit and pushed the
|
||||
panel wider.
|
||||
"""
|
||||
fmt = "{value:.3f}" if self._unit == "N" else "{value:.1f}"
|
||||
unavailable = t("chart.value_unavailable")
|
||||
for index, (value_label, unit_label, value) in enumerate(
|
||||
zip(self.value_labels, self.value_units, self._last_values)
|
||||
selecting = self._selection
|
||||
for index, (value_label, unit_label, detail_label, value) in enumerate(
|
||||
zip(
|
||||
self.value_labels,
|
||||
self.value_units,
|
||||
self.value_details,
|
||||
self._last_values,
|
||||
)
|
||||
):
|
||||
if not self._available[index]:
|
||||
available = self._available[index]
|
||||
if selecting is not None:
|
||||
peak = float(selecting.peaks[index])
|
||||
available = bool(np.isfinite(peak))
|
||||
value = peak
|
||||
if not available:
|
||||
# An axis with no curve in this unit: a dash, never a raw count
|
||||
# presented as a newton value.
|
||||
value_label.setText(unavailable)
|
||||
unit_label.setText("")
|
||||
detail_label.setText("")
|
||||
value_label.setToolTip(
|
||||
t("chart.value_unavailable_tip", channel=CHANNEL_LABELS[index])
|
||||
)
|
||||
continue
|
||||
value_label.setText(fmt.format(value=value))
|
||||
value_label.setToolTip("")
|
||||
value_label.setToolTip(
|
||||
t("chart.selection_peak_tip") if selecting is not None else ""
|
||||
)
|
||||
unit_label.setText(unit_display(self._unit))
|
||||
detail_label.setText(
|
||||
self._selection_detail(index) if selecting is not None else ""
|
||||
)
|
||||
self.unit_badge.setText(unit_display(self._unit))
|
||||
|
||||
def _selection_detail(self, index: int) -> str:
|
||||
"""``mean · σ`` for one channel of the current selection."""
|
||||
stats = self._selection
|
||||
if stats is None:
|
||||
return ""
|
||||
mean = float(stats.means[index])
|
||||
std = float(stats.stds[index])
|
||||
if not np.isfinite(mean):
|
||||
return ""
|
||||
return t(
|
||||
"chart.selection_detail",
|
||||
mean=f"{mean:.4g}",
|
||||
std=f"{std:.4g}" if np.isfinite(std) else "-",
|
||||
)
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.title.setText(t("panels.plots"))
|
||||
for badge, label in zip(self.channel_badges, CHANNEL_LABELS):
|
||||
@@ -1079,4 +1209,6 @@ class ForcePlotsPanel(QFrame):
|
||||
self.window_caption.setToolTip(t("chart.window_tip"))
|
||||
self._refresh_mode_label()
|
||||
self._refresh_window_label()
|
||||
self._render_value_cards()
|
||||
# Which readout a card carries depends on the mode, so the language switch
|
||||
# goes through the same path as a data refresh.
|
||||
self._update_selection_readout()
|
||||
|
||||
Reference in New Issue
Block a user