feat: camera help overlay, camera-error message, and session badge hover feedback

The controls cheatsheet gets its own help badge/overlay instead of
riding the detection legend; the camera-unavailable state now draws the
actual error reason bottom-center (pushed from MainWindow to all three
camera views); the session badge shows hover feedback and the vacant
overlay reads 'In viewing mode' with a grab-baton hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 09:43:30 +02:00
co-authored by Claude Fable 5
parent ac6a651414
commit 262260b956
3 changed files with 232 additions and 50 deletions
+9
View File
@@ -1417,6 +1417,14 @@ class MainWindow(QMainWindow):
def _on_sample_camera_error(self, message: str) -> None:
logger.warning(message)
self._show_samcam_feed_banner(message or "Sample camera feed unavailable")
# The camera overlays show the reason too, not just the boolean.
for cam in (
self.sample_camera,
getattr(self, "compact_sample_camera", None),
getattr(self, "portrait_sample_camera", None),
):
if cam is not None:
cam.set_camera_error_message(message or "Sample camera feed unavailable")
def _show_samcam_feed_banner(self, message: str) -> None:
self._samcam_feed_banner_message = message
@@ -1769,6 +1777,7 @@ class MainWindow(QMainWindow):
self.setStyleSheet(build_app_stylesheet(self._theme_mode))
# State colors are painted in code per DAQ tick — QSS can't reach them.
self.beamline_state_panel.set_theme(self._theme_mode)
self.sample_camera.set_theme(self._theme_mode)
if old_look is None:
return
overlay = QLabel(self)
+5 -1
View File
@@ -39,6 +39,9 @@ class BusyOverlayStyle:
overlay_border: QColor
overlay_text: QColor
accent_dot: str
# Hint line under the title — only the big sample-camera badge draws it;
# compact consumers (axis panel label, video badge) show text alone.
subtext: str = ""
def build_busy_overlay_style(
@@ -49,13 +52,14 @@ def build_busy_overlay_style(
) -> BusyOverlayStyle | None:
if session_state == SessionsStateEnum.Vacant:
return BusyOverlayStyle(
text="SESSION VACANT",
text="In viewing mode",
badge_bg=BUSY_YELLOW,
badge_fg=WHITE,
overlay_fill=qcolor(BUSY_YELLOW, 195),
overlay_border=qcolor(BUSY_YELLOW_BORDER, 235),
overlay_text=qcolor(WHITE),
accent_dot=BUSY_YELLOW_DOT,
subtext="Click here to grab baton if need to interact with GUI",
)
if session_state in {SessionsStateEnum.OwnedByElse, SessionsStateEnum.PendingYouToElse}:
+218 -49
View File
@@ -1,6 +1,7 @@
import math
import time
from enum import Enum
from typing import ClassVar
from aarecommon.config.logger import setup_logger
from aarecommon.math.coordinate import Coordinate, SmargonCoordinate
@@ -55,7 +56,9 @@ from aare.gui.styles import (
MARKER_GREEN,
PATH_END,
PATH_START,
SHADOW,
TARGET_COLORS,
THEME_SUNSET,
TOOLTIP_TEXT,
WHITE,
qcolor,
@@ -108,9 +111,14 @@ class SampleCameraImageLabel(QGraphicsView):
self._sam_cam = SampleCameraSettings(exposure=0.1, gain=100.0)
self._is_daq_busy = False
self._camera_available = True
self._camera_error_message: str | None = None
# Baton gate: watching allowed, operating not (main_window drives it).
self._operations_allowed = True
self._session_badge_rect: QRect | None = None # viewport coords
self._session_badge_hovered = False
# Hover polarity for the badge: light themes darken, Sunset brightens.
# Set via set_theme from MainWindow._apply_theme.
self._dark_theme = False
self._last_grid_update_ts = 0.0
self._grid_update_min_interval_s = 1.0 / 25.0
self._tell_state = None
@@ -131,10 +139,10 @@ class SampleCameraImageLabel(QGraphicsView):
self._show_target_coordinates = True
self._show_overlay_legend = True
self._compact_overlay_legend = False
# Legend stays collapsed to a "?" badge until clicked — the full box
# covers too much of the camera image to be always-on.
self._legend_expanded = False
self._legend_hit_rect: QRectF | None = None # viewport coords, set on paint
# "?" badge is a mouse-controls cheatsheet, decoupled from the legend —
# legend visibility is already handled by the panel checkboxes.
self._help_expanded = False
self._help_hit_rect: QRectF | None = None # viewport coords, set on paint
self._target_point = None
self._target_shape = None
self._target_color_name = "Cyan"
@@ -219,9 +227,18 @@ class SampleCameraImageLabel(QGraphicsView):
@Slot(bool)
def set_camera_available(self, available: bool):
self._camera_available = available
if available:
self._camera_error_message = None
self._update_camera_interaction_feedback()
self.update()
@Slot(str)
def set_camera_error_message(self, message: str):
# Thread errors arrive as "...unavailable: X" — reword to the
# "...unavailable because X" phrasing the overlay shows.
self._camera_error_message = message.replace(": ", " because ", 1)
self.update()
@Slot(dict)
def update_detections(self, payload: dict):
try:
@@ -277,6 +294,17 @@ class SampleCameraImageLabel(QGraphicsView):
return f"TELL {activity_name}".upper()
def _draw_status_text(
self, painter: QPainter, text: str, color, center_x: int, baseline_y: int, fm: QFontMetrics
):
# Solid colored text with a 1px shadow — survives any camera image
# behind it without a badge box.
x = center_x - fm.horizontalAdvance(text) // 2
painter.setPen(QPen(qcolor(SHADOW, 200)))
painter.drawText(QPoint(x + 1, baseline_y + 1), text)
painter.setPen(QPen(qcolor(color) if isinstance(color, str) else color))
painter.drawText(QPoint(x, baseline_y), text)
def _draw_busy_overlay(self, painter: QPainter):
if self._busy_overlay_style is None:
return
@@ -289,15 +317,44 @@ class SampleCameraImageLabel(QGraphicsView):
font = QFont()
font.setPointSize(24)
font.setBold(True)
painter.setFont(font)
font_metrics = QFontMetrics(font)
text_rect = font_metrics.boundingRect(style.text)
# Robot/busy WARNINGS are not clickable: no badge box, just solid
# text in the state's color. Only the session badges (a real click
# target) keep the button-like pill below.
if self._session_state not in (
SessionsStateEnum.Vacant,
SessionsStateEnum.OwnedByElse,
SessionsStateEnum.PendingYouToElse,
):
self._session_badge_rect = None
painter.setFont(font)
baseline = int(self.viewport().height() * 0.68) + font_metrics.ascent() // 2
self._draw_status_text(
painter,
style.text,
style.badge_bg,
self.viewport().width() // 2,
baseline,
font_metrics,
)
painter.restore()
return
sub_font = QFont()
sub_font.setPointSize(12)
sub_metrics = QFontMetrics(sub_font)
title_width = font_metrics.horizontalAdvance(style.text)
sub_width = sub_metrics.horizontalAdvance(style.subtext) if style.subtext else 0
padding_x = 20
padding_y = 14
bg_width = text_rect.width() + 2 * padding_x
bg_height = text_rect.height() + 2 * padding_y
sub_gap = 6
bg_width = max(title_width, sub_width) + 2 * padding_x
bg_height = font_metrics.height() + 2 * padding_y
if style.subtext:
bg_height += sub_gap + sub_metrics.height()
viewport_width = self.viewport().width()
viewport_height = self.viewport().height()
@@ -307,20 +364,43 @@ class SampleCameraImageLabel(QGraphicsView):
bg_rect = QRect(position_x, position_y, bg_width, bg_height)
# SESSION VACANT / GUEST MODE badges double as the click target for the
# grab/request menu, same as the _draw_session_overlay badge they hide.
session_badge = self._session_state in (
SessionsStateEnum.Vacant,
SessionsStateEnum.OwnedByElse,
SessionsStateEnum.PendingYouToElse,
)
self._session_badge_rect = bg_rect if session_badge else None
fill = QColor(style.overlay_fill)
if session_badge and self._session_badge_hovered:
# Hover: darker in the light themes, brighter in Sunset.
fill = fill.lighter(125) if self._dark_theme else fill.darker(115)
painter.setPen(QPen(style.overlay_border, 2, Qt.PenStyle.SolidLine))
painter.setBrush(style.overlay_fill)
painter.setBrush(fill)
painter.drawRoundedRect(bg_rect, 10, 10)
painter.setPen(QPen(style.overlay_text, 2, Qt.PenStyle.SolidLine))
text_pos = QPoint(position_x + padding_x, position_y + padding_y + font_metrics.ascent())
painter.drawText(text_pos, style.text)
painter.setFont(font)
title_x = position_x + (bg_width - title_width) // 2
title_y = position_y + padding_y + font_metrics.ascent()
painter.drawText(QPoint(title_x, title_y), style.text)
if style.subtext:
painter.setFont(sub_font)
sub_x = position_x + (bg_width - sub_width) // 2
sub_y = position_y + padding_y + font_metrics.height() + sub_gap + sub_metrics.ascent()
painter.drawText(QPoint(sub_x, sub_y), style.subtext)
painter.restore()
def _draw_session_overlay(self, painter: QPainter):
self._session_badge_rect = None
if self._busy_overlay_style is not None:
# Busy overlay drew (and owns) the session badge rect — don't clobber.
return
self._session_badge_rect = None
if self._session_state in (
SessionsStateEnum.OwnedByYou,
@@ -362,6 +442,10 @@ class SampleCameraImageLabel(QGraphicsView):
# Clicking the badge opens the session (grab/request) menu.
self._session_badge_rect = bg_rect
if self._session_badge_hovered:
# Same hover polarity as the busy-overlay badge.
bg_color = bg_color.lighter(125) if self._dark_theme else bg_color.darker(115)
painter.setPen(QPen(qcolor(WHITE, 220)))
painter.setBrush(bg_color)
painter.drawRoundedRect(bg_rect, 10, 10)
@@ -382,28 +466,21 @@ class SampleCameraImageLabel(QGraphicsView):
font.setPointSize(22)
font.setBold(True)
painter.setFont(font)
text = "Sample camera feed unavailable"
fm = QFontMetrics(font)
text_rect = fm.boundingRect(text)
padding = 16
position_x = 50
position_y = 120
bg_rect = QRect(
position_x - padding,
position_y - padding,
text_rect.width() + 2 * padding,
text_rect.height() + 2 * padding,
# Bottom-center, no badge box — solid colored text (the pill read
# as a button). The camera thread's reason is appended upstream as
# "... because <reason>" when it is known.
margin = 18
text = fm.elidedText(
self._camera_error_message or "Sample camera feed unavailable",
Qt.TextElideMode.ElideRight,
self.viewport().width() - 2 * margin,
)
baseline = self.viewport().height() - margin - fm.descent()
self._draw_status_text(
painter, text, MARK_BADGE_BG, self.viewport().width() // 2, baseline, fm
)
painter.setPen(QPen(qcolor(WHITE, 220), 2))
painter.setBrush(qcolor(MARK_BADGE_BG, 180))
painter.drawRoundedRect(bg_rect, 10, 10)
painter.setPen(QPen(qcolor(WHITE)))
painter.drawText(QPoint(position_x, position_y + fm.ascent()), text)
painter.restore()
@@ -418,20 +495,21 @@ class SampleCameraImageLabel(QGraphicsView):
self._draw_detections(painter, rect)
self._draw_target_point(painter)
self._draw_overlay_legend(painter)
self._draw_help_overlay(painter)
def resizeEvent(self, event):
super().resizeEvent(event)
self._scaling()
def mousePressEvent(self, event):
# Legend badge first: pure UI affordance, must work even when camera
# Help badge first: pure UI affordance, must work even when camera
# interaction is disabled (session overlay etc.).
if (
event.button() == Qt.MouseButton.LeftButton
and self._legend_hit_rect is not None
and self._legend_hit_rect.contains(QPointF(self.viewport().mapFrom(self, event.pos())))
and self._help_hit_rect is not None
and self._help_hit_rect.contains(QPointF(self.viewport().mapFrom(self, event.pos())))
):
self._legend_expanded = not self._legend_expanded
self._help_expanded = not self._help_expanded
self.update()
event.accept()
return
@@ -490,7 +568,27 @@ class SampleCameraImageLabel(QGraphicsView):
self.switch_raster_grid.emit()
self._raster_mgr.resize_active_grid(self.end_point)
@Slot(str)
def set_theme(self, theme: str):
self._dark_theme = theme == THEME_SUNSET
self.update()
def leaveEvent(self, event):
if self._session_badge_hovered:
self._session_badge_hovered = False
self.update()
super().leaveEvent(event)
def mouseMoveEvent(self, event):
# Badge hover feedback must run BEFORE the interaction gate: the
# badge is visible precisely when interaction is disabled.
hovered = self._session_badge_rect is not None and self._session_badge_rect.contains(
self.viewport().mapFrom(self, event.pos())
)
if hovered != self._session_badge_hovered:
self._session_badge_hovered = hovered
self.update()
if not self._camera_interaction_enabled():
return
@@ -900,7 +998,7 @@ class SampleCameraImageLabel(QGraphicsView):
painter.setBrush(qcolor(LEGEND_BG, 190))
painter.drawRoundedRect(bubble_rect, 8, 8)
painter.setPen(QPen(qcolor(WHITE), 1))
painter.setPen(QPen(qcolor(LEGEND_TEXT), 1))
painter.drawText(
QPointF(bubble_rect.left() + 8, bubble_rect.top() + 7 + fm.ascent()), label_text
)
@@ -971,8 +1069,32 @@ class SampleCameraImageLabel(QGraphicsView):
return lines
def _draw_legend_badge(self, painter: QPainter):
# ponytail: painted circle, not a real QWidget button — the legend it
# (header, [entries]) — kept concise on purpose; the full table lives in
# docs/cheatsheet.md.
_HELP_SECTIONS: ClassVar[list[tuple[str, list[str]]]] = [
(
"Sample camera",
[
"Left click — move sample here",
"Shift + Left click — Z-alignment move",
"Right click — context menu",
"Wheel — rotate omega 90° (Shift: 10°)",
"Ctrl / Alt + Wheel — exposure coarse / fine",
],
),
(
"Raster grid",
[
"Right drag — draw grid (on grid: resize)",
"Left drag — move grid",
"Ctrl + Left click — move sample under grid",
"Shift + move — inspect raster image at cursor",
],
),
]
def _draw_help_badge(self, painter: QPainter):
# ponytail: painted circle, not a real QWidget button — the overlay it
# toggles is painter-drawn too, and a widget would need layout juggling.
diameter = 22
rect = QRectF(18, self.viewport().height() - diameter - 18, diameter, diameter)
@@ -980,7 +1102,7 @@ class SampleCameraImageLabel(QGraphicsView):
painter.save()
painter.resetTransform()
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.setPen(QPen(qcolor(WHITE, 60), 1))
painter.setPen(QPen(qcolor(LEGEND_TEXT, 60), 1))
painter.setBrush(qcolor(LEGEND_BG, 170))
painter.drawEllipse(rect)
@@ -992,15 +1114,62 @@ class SampleCameraImageLabel(QGraphicsView):
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "?")
painter.restore()
self._legend_hit_rect = rect
self._help_hit_rect = rect
def _draw_overlay_legend(self, painter: QPainter):
self._legend_hit_rect = None
if not self._legend_should_show():
def _draw_help_overlay(self, painter: QPainter):
self._help_hit_rect = None
if not self._help_expanded:
self._draw_help_badge(painter)
return
if not self._legend_expanded:
self._draw_legend_badge(painter)
painter.save()
painter.resetTransform()
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
font = QFont()
font.setPointSize(9)
header_font = QFont(font)
header_font.setBold(True)
fm = QFontMetrics(font)
header_fm = QFontMetrics(header_font)
line_height = fm.height() + 4
header_height = header_fm.height() + 6
padding = 10
max_width = 0
n_lines = 0
for header, entries in self._HELP_SECTIONS:
max_width = max(max_width, header_fm.horizontalAdvance(header))
n_lines += len(entries)
for entry in entries:
max_width = max(max_width, fm.horizontalAdvance(entry))
width = max_width + padding * 2
height = len(self._HELP_SECTIONS) * header_height + n_lines * line_height + padding * 2
bg_rect = QRectF(18, max(18, self.viewport().height() - height - 18), width, height)
self._help_hit_rect = bg_rect # click anywhere on the box to close
painter.setPen(QPen(qcolor(LEGEND_TEXT, 60), 1))
painter.setBrush(qcolor(LEGEND_BG, 190))
painter.drawRoundedRect(bg_rect, 8, 8)
y = bg_rect.top() + padding
for header, entries in self._HELP_SECTIONS:
painter.setFont(header_font)
painter.setPen(QPen(qcolor(LEGEND_TEXT), 1))
painter.drawText(QPointF(bg_rect.left() + padding, y + header_fm.ascent()), header)
y += header_height
painter.setFont(font)
painter.setPen(QPen(qcolor(LEGEND_TEXT), 1))
for entry in entries:
painter.drawText(QPointF(bg_rect.left() + padding, y + fm.ascent()), entry)
y += line_height
painter.restore()
def _draw_overlay_legend(self, painter: QPainter):
if not self._legend_should_show() or self._help_expanded:
return
painter.save()
@@ -1018,7 +1187,8 @@ class SampleCameraImageLabel(QGraphicsView):
text_padding = 6 if self._compact_overlay_legend else 8
section_padding = 8 if self._compact_overlay_legend else 10
left = 18
top = self.viewport().height() - (len(lines) * line_height + 24)
# Bottom-anchored above the "?" help badge (18px margin + 22px badge + gap).
top = self.viewport().height() - (len(lines) * line_height + 16) - 48
max_text_width = 0
for text, _color in lines:
@@ -1028,8 +1198,7 @@ class SampleCameraImageLabel(QGraphicsView):
height = len(lines) * line_height + 16
bg_rect = QRectF(left, max(18, top), width, height)
self._legend_hit_rect = bg_rect # click anywhere on the box to collapse
painter.setPen(QPen(qcolor(WHITE, 60), 1))
painter.setPen(QPen(qcolor(LEGEND_TEXT, 60), 1))
painter.setBrush(qcolor(LEGEND_BG, 170))
painter.drawRoundedRect(bg_rect, 8, 8)