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:
@@ -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)
|
||||
|
||||
@@ -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}:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user